<?php
/**
 * Stripe Gateway — PaymentIntents API.
 *
 * PAYMENT FLOW
 * ------------
 * 1. POST /checkout/place-order
 *    → Order created (status: pending)
 *    → This gateway creates a Stripe PaymentIntent
 *    → Returns { client_secret, publishable_key } in payment_data
 * 2. Checkout JS receives payment_data and calls:
 *    stripe.confirmCardPayment(client_secret, { payment_method: { card: cardElement } })
 * 3. Stripe processes the charge; on success fires a webhook
 * 4. Webhook: POST /wp-json/titancart/v1/webhooks/stripe
 *    → Verifies signature, updates transaction + order status
 *
 * SETTINGS FIELDS (stored in payment_methods.settings JSON)
 * ----------------------------------------------------------
 *   publishable_key  — Stripe publishable key (pk_live_... / pk_test_...)
 *   secret_key       — Stripe secret key (sk_live_... / sk_test_...)
 *   webhook_secret   — Stripe webhook signing secret (whsec_...)
 *
 * @package TitanCart\Modules\Payments\Gateways
 */

namespace TitanCart\Modules\Payments\Gateways;

use TitanCart\Modules\Payments\AbstractGateway;

defined( 'ABSPATH' ) || exit;

class StripeGateway extends AbstractGateway {

    const SLUG              = 'stripe';
    const API_BASE          = 'https://api.stripe.com/v1';
    const PAYMENT_INTENT_EP = self::API_BASE . '/payment_intents';

    // ══════════════════════════════════════════════════════════════════════════
    // INTERFACE IMPLEMENTATION
    // ══════════════════════════════════════════════════════════════════════════

    public function get_label(): string {
        return __( 'Credit / Debit Card (Stripe)', 'titancart-lite' );
    }

    /**
     * Stripe vaults a reusable PaymentMethod. KI-79 (S394).
     *
     * Traced, not assumed — all three parts of AbstractGateway's test hold:
     *   1. `charge_off_session()` exists (L677, S213);
     *   2. the initial checkout vaults — a > $0 PaymentIntent carries
     *      `setup_future_usage`, and the $0 case takes the SetupIntent branch
     *      (`create_setup_intent()`, L312) — either way a pm_xxx is stored to
     *      `tc_subscription_payment_tokens` with its cus_xxx;
     *   3. `SubscriptionPlugin::charge_renewal_via_stripe()` (L1501) is
     *      registered on `tcs_charge_renewal` and routes 'stripe' tokens to
     *      `charge_off_session()` (L1541).
     *
     * @since S394 (KI-79)
     */
    public function supports_subscriptions(): bool {
        return true;
    }

    public function get_settings_fields(): array {
        $fields = [
            [
                'key'     => 'publishable_key',
                'label'   => __( 'Publishable Key', 'titancart-lite' ),
                'type'    => 'text',
                'default' => '',
                'hint'    => __( 'Starts with pk_test_ (test) or pk_live_ (live). Find it at dashboard.stripe.com → Developers → API Keys.', 'titancart-lite' ),
            ],
            [
                'key'     => 'secret_key',
                'label'   => __( 'Secret Key', 'titancart-lite' ),
                'type'    => 'password',
                'default' => '',
                'hint'    => __( 'Starts with sk_test_ (test) or sk_live_ (live). Never share this key publicly.', 'titancart-lite' ),
            ],
            [
                'key'     => 'webhook_secret',
                'label'   => __( 'Webhook Signing Secret', 'titancart-lite' ),
                'type'    => 'password',
                'default' => '',
                'hint'    => __( 'Starts with whsec_. Found at Stripe Dashboard → Developers → Webhooks → click your endpoint → Signing secret. This is NOT the restricted key from the API Keys page.', 'titancart-lite' ),
            ],
            [
                'key'     => 'payment_type',
                'label'   => __( 'Payment Type', 'titancart-lite' ),
                'type'    => 'select',
                'default' => 'stripe_checkout',
                'options' => [
                    'stripe_checkout' => __( 'Stripe Checkout (redirect to Stripe-hosted page)', 'titancart-lite' ),
                    'card'            => __( 'Card Elements (inline card form on your site)',     'titancart-lite' ),
                ],
                'hint'    => __( 'Stripe Checkout is simpler and handles SCA/3D Secure automatically. Card Elements gives you more design control.', 'titancart-lite' ),
            ],
            [
                'key'     => 'currency',
                'label'   => __( 'Currency', 'titancart-lite' ),
                'type'    => 'text',
                'default' => 'USD',
                'hint'    => __( '3-letter ISO code (e.g. USD, EUR, GBP). Must match a currency enabled in your Stripe account.', 'titancart-lite' ),
            ],
            [
                'key'     => 'country',
                'label'   => __( 'Account Country', 'titancart-lite' ),
                'type'    => 'text',
                'default' => 'US',
                'hint'    => __( '2-letter ISO country code where your Stripe account is registered (e.g. US, GB, CA).', 'titancart-lite' ),
            ],
        ];

        /**
         * Filter: titancart_stripe_settings_fields
         *
         * Lets extensions append (or otherwise mutate) the Stripe payment-
         * method settings form fields. Used by the marketplace extension
         * at B6 (S193) to append `connect_webhook_secret` — a SEPARATE
         * signing secret for the Connect webhook endpoint at
         * /wp-json/titancart/v1/webhooks/stripe/connect (distinct from
         * the `webhook_secret` above which signs payment events on
         * /wp-json/titancart/v1/webhooks/stripe).
         *
         * Pattern #158 fifth instance — cross-extension filter hook. Prior
         * four instances:
         *   - S185 `titancart_products_list_response`        (response-layer enrichment)
         *   - S187 `titancart_orders_list_where`             (query-layer scoping)
         *   - S190 `titancart_order_item_data`               (write-layer enrichment)
         *   - S192 `titancart_stripe_payment_intent_params`  (gateway-params layer)
         * Fifth instance covers the settings-fields layer — same additive
         * contract, same listener discipline, same single-direction
         * extension semantics. The pattern is now codified across five
         * distinct REST lifecycle stages (response, query, write,
         * gateway-params, settings-fields).
         *
         * Contract:
         *   - Listeners MAY ADD new field entries to $fields. Each entry
         *     MUST follow the existing shape:
         *       [
         *         'key'     => string  (machine name, snake_case),
         *         'label'   => string  (translated UI label),
         *         'type'    => string  ('text' | 'password' | 'select'),
         *         'default' => mixed   (initial value),
         *         'hint'    => string  (translated helper text),
         *         'options' => array   (only for type='select'),
         *       ]
         *   - Listeners SHOULD NOT MUTATE existing entries. Renaming
         *     labels diverges admin UX across installs; changing types
         *     can corrupt stored values; removing entries breaks the
         *     gateway. The PaymentsController treats this list as the
         *     authoritative form schema, including for password-masking
         *     in REST responses and array_merge during update_method.
         *   - Listeners SHOULD use the SAME translation domain as their
         *     own extension (not 'titancart') so site-language switching
         *     works correctly.
         *   - Storage is JSON-blob on tc_payment_methods.settings — no
         *     schema migration needed when listeners add fields.
         *
         * @since 0.13.0  S193 (Track B Module B6 — Stripe Connect onboarding)
         *
         * @param array $fields Settings-fields array shipped by TC core.
         */
        $fields = apply_filters( 'titancart_stripe_settings_fields', $fields );

        return $fields;
    }

    /**
     * Create a Stripe PaymentIntent and return the client_secret for JS confirmation.
     *
     * {@inheritdoc}
     */
    public function process_payment( int $order_id, array $order, array $settings ): array {
        $secret_key      = $this->setting( $settings, 'secret_key' );
        $publishable_key = $this->setting( $settings, 'publishable_key' );

        if ( ! $secret_key || ! $publishable_key ) {
            return $this->fail_result( __( 'Stripe is not configured. Please contact support.', 'titancart-lite' ) );
        }

        // ── Vault-only ($0) orders → SetupIntent, not PaymentIntent (S278, B2) ──
        // Stripe rejects a $0 PaymentIntent. When the payable total is $0 but a
        // subscription line still needs a card vaulted for a deferred first charge
        // (e.g. a beta "preorder" subscription whose billing starts on a future
        // floor date), create a SetupIntent — which saves a payment method for
        // off-session reuse without charging — instead. Gated behind a filter so a
        // genuinely free order (free product / full coupon, no vault need) is
        // unaffected; the Subscriptions extension returns true here only for a $0
        // order carrying a pending deferred-start subscription. Mixed carts whose
        // total is > 0 keep the normal PaymentIntent path (its setup_future_usage
        // already vaults the card), so this branch is strictly the $0 case.
        $vault_only = ( (float) ( $order['total'] ?? 0 ) ) <= 0
            && (bool) apply_filters( 'titancart_order_requires_vault_setup', false, $order_id, $order );
        if ( $vault_only ) {
            return $this->create_setup_intent( $order_id, $order, $secret_key, $publishable_key );
        }

        // Amount must be in smallest currency unit (cents for USD).
        $amount   = (int) round( (float) $order['total'] * 100 );
        $currency = strtolower( $order['currency'] ?? 'usd' );

        // Resolve the customer email. CheckoutManager passes billing data nested
        // under 'billing' in $order_data, but older callers may pass it as a flat
        // 'billing_email' key — support both. Stripe rejects an empty string for
        // receipt_email ("Invalid email address: ''."), so only include the key
        // when we actually have a usable address.
        $email = '';
        if ( ! empty( $order['billing']['email'] ) ) {
            $email = (string) $order['billing']['email'];
        } elseif ( ! empty( $order['billing_email'] ) ) {
            $email = (string) $order['billing_email'];
        }
        $email = is_email( $email ) ? $email : '';

        $params = [
            'amount'                    => $amount,
            'currency'                  => $currency,
            'automatic_payment_methods' => [ 'enabled' => 'true' ],
            'metadata'                  => [
                'order_id'     => $order_id,
                'order_number' => $order['order_number'] ?? '',
            ],
            'description'               => sprintf(
                /* translators: %s: order number */
                __( 'TitanCart order %s', 'titancart-lite' ),
                $order['order_number'] ?? $order_id
            ),
        ];
        if ( $email !== '' ) {
            $params['receipt_email'] = $email;
        }

        /**
         * Filter: titancart_stripe_payment_intent_params
         *
         * Lets extensions inject additional fields onto the Stripe
         * PaymentIntent params BEFORE the API call. Used by the marketplace
         * extension at B6 (S192+) to inject `transfer_group` so the PI is
         * tied to the N follow-on Transfer objects fired per vendor after
         * the charge succeeds (Pure SCT — Separate Charges and Transfers —
         * marketplace pattern locked at S192).
         *
         * Pattern #158 fourth instance — cross-extension filter hook. Prior
         * three instances:
         *   - S185 `titancart_products_list_response` (response-layer enrichment)
         *   - S187 `titancart_orders_list_where`     (query-layer scoping)
         *   - S190 `titancart_order_item_data`       (write-layer enrichment)
         * Fourth instance graduates the pattern from "emergent observation"
         * to "this is how TitanCart does cross-extension hooks." Same
         * additive contract as the prior three; same listener discipline.
         *
         * Contract:
         *   - Listeners MAY ADD keys to $params that Stripe's
         *     /v1/payment_intents endpoint accepts (e.g. transfer_group,
         *     transfer_data, on_behalf_of, application_fee_amount,
         *     statement_descriptor, capture_method). The build_stripe_body
         *     helper recursively form-encodes whatever shape lands here, so
         *     nested associative arrays are supported (e.g.
         *     `transfer_data => ['destination' => 'acct_X', 'amount' => 100]`).
         *   - Listeners MUST NOT REMOVE existing keys. Removing `amount`,
         *     `currency`, or `automatic_payment_methods` would break the PI
         *     creation outright. Mutating existing values is permitted but
         *     strongly discouraged — overlap with TC core's own logic.
         *   - Listeners SHOULD be idempotent. Retry paths on transient
         *     network failures may re-fire this filter against fresh params
         *     for the same order.
         *   - $order_id is the freshly-created order's primary key.
         *   - $order is the order data array passed to process_payment()
         *     (read-only context; includes billing, totals, payment_method_id,
         *     etc. but NOT the items list — listeners that need items must
         *     query tc_order_items keyed by $order_id).
         *
         * Failure mode:
         *   If a listener returns a non-array, the PI creation will fail
         *   with a Stripe error (likely 400 invalid_request). Pattern #158
         *   discipline applies — listeners are responsible for returning a
         *   well-formed params array. No defense-in-depth guard here.
         *
         * @since 0.12.x  S192 (Track B Module B6 — Stripe Connect Express)
         *
         * @param array $params    Resolved PI params array, pre-POST.
         * @param int   $order_id  Newly-created order's primary key.
         * @param array $order     Order data passed to process_payment().
         */
        $params = apply_filters(
            'titancart_stripe_payment_intent_params',
            $params,
            $order_id,
            $order
        );

        $response = $this->stripe_post( '/payment_intents', $secret_key, $params );

        if ( ! $response['ok'] ) {
            $error = $response['body']['error']['message'] ?? __( 'Stripe payment failed.', 'titancart-lite' );
            titancart_log( sprintf( 'TitanCart Stripe process_payment error: %s', $error ) );
            $this->log_transaction( $order_id, (int) ( $order['payment_method_id'] ?? 0 ), self::SLUG, 'failed', (float) $order['total'], $order['currency'] ?? 'USD', 'charge', 'failed', $response['body'] );
            return $this->fail_result( $error );
        }

        $intent = $response['body'];

        // Log the pending transaction.
        $this->log_transaction(
            $order_id,
            (int) ( $order['payment_method_id'] ?? 0 ),
            self::SLUG,
            $intent['id'],
            (float) $order['total'],
            $order['currency'] ?? 'USD',
            'charge',
            'pending',
            $intent
        );

        return $this->success_result(
            $intent['id'],
            __( 'Payment initiated — awaiting card confirmation.', 'titancart-lite' ),
            [
                'requires_action'  => true,
                'client_secret'    => $intent['client_secret'],
                'publishable_key'  => $publishable_key,
                'payment_intent_id'=> $intent['id'],
            ]
        );
    }

    /**
     * Create a Stripe SetupIntent to vault a card WITHOUT charging (S278, B2).
     *
     * Used for $0 "vault-only" orders — e.g. a preorder / deferred-start
     * subscription whose first charge fires on a future floor date. A
     * SetupIntent collects and saves a payment method for off-session reuse
     * (the renewal cron later charges it via charge_off_session) without taking
     * any money now.
     *
     * Returns the same client_secret + requires_action shape as process_payment,
     * PLUS `setup_intent => true` so the checkout JS confirms with
     * stripe.confirmCardSetup() rather than confirmCardPayment(). The
     * Subscriptions extension injects the Stripe `customer` via
     * `titancart_stripe_setup_intent_params` and captures the resulting
     * PaymentMethod via `titancart_stripe_setup_intent_succeeded` (the
     * SetupIntent siblings of the PaymentIntent params/succeeded hooks).
     *
     * @param int    $order_id        Newly-created order's primary key.
     * @param array  $order           Order data passed to process_payment().
     * @param string $secret_key      Stripe secret key.
     * @param string $publishable_key Stripe publishable key (returned to JS).
     * @return array                  Standard gateway result array.
     */
    private function create_setup_intent( int $order_id, array $order, string $secret_key, string $publishable_key ): array {
        $params = [
            'usage'                     => 'off_session',
            'automatic_payment_methods' => [ 'enabled' => 'true' ],
            'metadata'                  => [
                'order_id'     => $order_id,
                'order_number' => $order['order_number'] ?? '',
                'vault_only'   => '1',
            ],
        ];

        /**
         * Filter: titancart_stripe_setup_intent_params (S278, B2)
         *
         * SetupIntent sibling of `titancart_stripe_payment_intent_params`. Lets
         * the Subscriptions extension inject the Stripe `customer` (cus_xxx) so
         * the vaulted PaymentMethod is attached to a Customer and reusable
         * off-session at renewal time. Same additive contract as the PI params
         * filter: listeners MAY ADD keys Stripe's /v1/setup_intents accepts,
         * MUST NOT remove existing keys, and SHOULD be idempotent.
         *
         * @param array $params   Resolved SetupIntent params, pre-POST.
         * @param int   $order_id  Newly-created order's primary key.
         * @param array $order     Order data passed to process_payment().
         */
        $params = apply_filters( 'titancart_stripe_setup_intent_params', $params, $order_id, $order );

        $response = $this->stripe_post( '/setup_intents', $secret_key, $params );

        if ( ! $response['ok'] ) {
            $error = $response['body']['error']['message'] ?? __( 'Stripe card setup failed.', 'titancart-lite' );
            titancart_log( sprintf( 'TitanCart Stripe create_setup_intent error: %s', $error ) );
            // 'authorize' is the closest allowed payment_transactions.type for a
            // no-charge funding-source setup (the type ENUM has no 'setup').
            $this->log_transaction( $order_id, (int) ( $order['payment_method_id'] ?? 0 ), self::SLUG, 'failed', 0.0, $order['currency'] ?? 'USD', 'authorize', 'failed', $response['body'] );
            return $this->fail_result( $error );
        }

        $intent = $response['body'];

        $this->log_transaction(
            $order_id,
            (int) ( $order['payment_method_id'] ?? 0 ),
            self::SLUG,
            $intent['id'],
            0.0,
            $order['currency'] ?? 'USD',
            'authorize',
            'pending',
            $intent
        );

        return $this->success_result(
            $intent['id'],
            __( 'Card setup initiated — awaiting confirmation.', 'titancart-lite' ),
            [
                'requires_action' => true,
                'setup_intent'    => true,
                'client_secret'   => $intent['client_secret'],
                'publishable_key' => $publishable_key,
                'setup_intent_id' => $intent['id'],
            ]
        );
    }

    /**
     * Issue a Stripe refund against a PaymentIntent charge.
     *
     * {@inheritdoc}
     */
    public function refund( int $order_id, string $transaction_id, float $amount, array $settings ): array {
        $secret_key = $this->setting( $settings, 'secret_key' );

        if ( ! $secret_key ) {
            return [ 'success' => false, 'refund_id' => '', 'message' => __( 'Stripe not configured.', 'titancart-lite' ) ];
        }

        // Retrieve the charge ID from the PaymentIntent.
        $pi_response = $this->stripe_get( '/payment_intents/' . $transaction_id, $secret_key );

        if ( ! $pi_response['ok'] ) {
            return [ 'success' => false, 'refund_id' => '', 'message' => __( 'Could not retrieve PaymentIntent.', 'titancart-lite' ) ];
        }

        $charge_id = $pi_response['body']['latest_charge'] ?? null;

        if ( ! $charge_id ) {
            return [ 'success' => false, 'refund_id' => '', 'message' => __( 'No charge found to refund.', 'titancart-lite' ) ];
        }

        $refund_response = $this->stripe_post( '/refunds', $secret_key, [
            'charge' => $charge_id,
            'amount' => (int) round( $amount * 100 ),
        ] );

        if ( ! $refund_response['ok'] ) {
            $error = $refund_response['body']['error']['message'] ?? __( 'Refund failed.', 'titancart-lite' );
            return [ 'success' => false, 'refund_id' => '', 'message' => $error ];
        }

        $refund = $refund_response['body'];

        // Log the refund transaction.
        $order = $this->db->getRow( "SELECT * FROM {$this->db->table('orders')} WHERE id = ?i", $order_id );
        $this->log_transaction( $order_id, (int) ( $order['payment_method_id'] ?? 0 ), self::SLUG, $refund['id'], $amount, $order['currency'] ?? 'USD', 'refund', 'completed', $refund );

        return [
            'success'   => true,
            'refund_id' => $refund['id'],
            'message'   => __( 'Refund issued successfully.', 'titancart-lite' ),
        ];
    }

    // ══════════════════════════════════════════════════════════════════════════
    // ASYNC PAYMENT VERIFICATION
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Verify a Stripe PaymentIntent has actually settled.
     *
     * Called by `CheckoutManager::finalize_order()` after the JS completes the
     * client-side `stripe.confirmCardPayment()` step. We re-query Stripe's
     * `/payment_intents/{id}` endpoint server-side so the cart-commit decision
     * does NOT rely on what the JS told us — a malicious caller cannot fake a
     * "succeeded" status; only Stripe can.
     *
     * Accepts both `succeeded` (card payments — the common case) and
     * `processing` (async payment methods like ACH that settle asynchronously
     * but where the customer has nonetheless committed and the cart should be
     * destroyed; the webhook will reconcile final order status later).
     *
     * Any other PI status — `requires_payment_method`, `requires_confirmation`,
     * `requires_action`, `canceled` — returns false and refuses the finalize.
     *
     * @param  string $transaction_id  Stripe PaymentIntent ID (pi_...).
     * @param  array  $settings        Gateway settings; must include `secret_key`.
     * @return bool   True if PI status is `succeeded` or `processing`. False otherwise.
     */
    public function verify_async_payment( string $transaction_id, array $settings ): bool {
        $secret_key = $this->setting( $settings, 'secret_key' );

        if ( ! $secret_key || ! $transaction_id ) {
            return false;
        }

        $response = $this->stripe_get( '/payment_intents/' . $transaction_id, $secret_key );

        if ( ! $response['ok'] ) {
            return false;
        }

        $status = $response['body']['status'] ?? '';

        return in_array( $status, [ 'succeeded', 'processing' ], true );
    }

    /**
     * Verify a Stripe SetupIntent has actually succeeded (S279, B2).
     *
     * SetupIntent sibling of verify_async_payment(). Called by
     * CheckoutManager::finalize_order() after the browser completes
     * stripe.confirmCardSetup() for a $0 vault-only order (a preorder /
     * delayed-start subscription whose first charge fires on a future floor
     * date). We re-query /setup_intents/{id} server-side so the commit decision
     * never trusts what the JS reported — only Stripe can confirm the card was
     * actually saved.
     *
     * A SetupIntent has no `processing` state in the way a PaymentIntent does;
     * the only state that means "card vaulted" is `succeeded`. Any other status
     * (requires_payment_method / requires_confirmation / requires_action /
     * canceled) returns verified=false and refuses the finalize.
     *
     * Returns the full SetupIntent object alongside the verdict so the caller
     * can hand the resolved payment_method + customer to the post-commit
     * handler without a second round-trip.
     *
     * @param  string $setup_intent_id  Stripe SetupIntent ID (seti_...).
     * @param  array  $settings         Gateway settings; must include `secret_key`.
     * @return array{verified: bool, intent: array}
     */
    public function verify_setup_intent( string $setup_intent_id, array $settings ): array {
        $secret_key = $this->setting( $settings, 'secret_key' );

        if ( ! $secret_key || ! $setup_intent_id ) {
            return [ 'verified' => false, 'intent' => [] ];
        }

        $response = $this->stripe_get( '/setup_intents/' . $setup_intent_id, $secret_key );

        if ( ! $response['ok'] ) {
            return [ 'verified' => false, 'intent' => [] ];
        }

        $intent = is_array( $response['body'] ) ? $response['body'] : [];
        $status = $intent['status'] ?? '';

        return [
            'verified' => ( $status === 'succeeded' ),
            'intent'   => $intent,
        ];
    }

    /**
     * Handle a confirmed Stripe SetupIntent (S279, B2).
     *
     * Vault-only sibling of on_payment_intent_succeeded(). Marks the pending
     * `authorize`-type transaction row completed (the SetupIntent path logs
     * type='authorize' since payment_transactions.type has no 'setup' member —
     * #286), advances the order, and fires titancart_stripe_setup_intent_succeeded
     * so the Subscriptions extension can vault the now-known PaymentMethod as a
     * renewal token (the SetupIntent object carries the same `payment_method` +
     * `customer` keys the PaymentIntent path reads, so the same capture handler
     * serves both actions).
     *
     * Invoked from two places, both idempotent:
     *   - CheckoutManager::finalize_order() (synchronous, after the JS confirms)
     *     — passes the matched txn row.
     *   - the setup_intent.succeeded webhook (belt-and-suspenders) — passes none,
     *     so we resolve the txn from the intent ID, mirroring the PI webhook.
     *
     * @param array      $intent  The Stripe SetupIntent object.
     * @param array|null $txn     Matching tc_payment_transactions row, or null.
     */
    public function handle_setup_intent_succeeded( array $intent, ?array $txn = null ): void {
        $seti_id = $intent['id'] ?? '';
        if ( ! $seti_id ) {
            return;
        }

        if ( ! is_array( $txn ) || empty( $txn ) ) {
            $txn = titancart_db()->getRow(
                "SELECT * FROM {$this->db->table('payment_transactions')}
                 WHERE transaction_id = ?s AND gateway = ?s
                 ORDER BY id DESC LIMIT 1",
                $seti_id,
                self::SLUG
            );
        }

        if ( ! $txn ) {
            titancart_log( "TitanCart Stripe: no transaction found for SetupIntent {$seti_id}" );
            return;
        }

        // Already finalized — keep this idempotent across the finalize + webhook
        // paths and Stripe's at-least-once webhook delivery.
        if ( ( $txn['status'] ?? '' ) === 'completed' ) {
            return;
        }

        // Mark the vault transaction completed and refresh its gateway_response
        // with the post-confirmation SetupIntent (payment_method / customer are
        // null on the pre-confirmation snapshot captured at create time).
        $this->update_transaction_status( (int) $txn['id'], 'completed', $intent );

        // Advance the order. A $0 preorder has nothing to charge now; moving it
        // to 'processing' lets the subscription extension's on_order_status_changed
        // listener activate the pending sub (whose first charge is deferred to the
        // floor date). Mirrors the PI path's advance-to-processing.
        $this->set_order_status(
            (int) $txn['order_id'],
            'processing',
            __( 'Card saved for deferred billing (preorder).', 'titancart-lite' ),
            true
        );

        /**
         * Action: titancart_stripe_setup_intent_succeeded (S279, B2)
         *
         * SetupIntent sibling of titancart_stripe_payment_intent_succeeded.
         * Fires after a Stripe SetupIntent succeeds (a $0 vault-only order), the
         * transaction row is marked completed, and the order has been advanced.
         *
         * The Subscriptions extension listens here to capture the saved
         * PaymentMethod as a renewal token — the same handler that listens to
         * the PaymentIntent action, because a succeeded SetupIntent object
         * exposes the same `payment_method` and `customer` keys.
         *
         * Same listener discipline as the PI action (Pattern #158 / do_action
         * side-effect convention): listeners MUST NOT block or throw, SHOULD be
         * idempotent (this can fire from both finalize and the webhook), and
         * handle their own errors out of band.
         *
         * @param array $intent    The full Stripe SetupIntent object.
         * @param array $txn       The matching tc_payment_transactions row.
         * @param int   $order_id  The order's primary key.
         */
        do_action(
            'titancart_stripe_setup_intent_succeeded',
            $intent,
            $txn,
            (int) $txn['order_id']
        );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // OFF-SESSION CHARGING (Pattern #181 — subscription renewal target)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Charge a stored PaymentMethod off-session.
     *
     * Used by the subscription extension's `tcs_charge_renewal` filter
     * handler (Pattern #181 dispatch) to bill a customer's stored card
     * without a checkout flow. The PaymentMethod must already be attached
     * to the Stripe Customer (handled at original-checkout time via
     * `setup_future_usage='off_session'` + `customer=cus_xxx` on the
     * PaymentIntent — the subscription extension's filter handler on
     * `titancart_stripe_payment_intent_params` injects both).
     *
     * The PI is created with `confirm=true` and `off_session=true`, which
     * tells Stripe to attempt the charge immediately without a
     * client-side confirm step. Strong Customer Authentication (SCA)
     * declines surface here as `authentication_required` errors — the
     * caller (subscription extension) translates these to past_due
     * status and the standard payment-failed notification.
     *
     * Idempotency: callers SHOULD pass an `idempotency_key` in
     * `$metadata` (e.g., "tcs-renew-{subscription_id}-{renewal_count}");
     * when not provided, the method falls back to a derived key built
     * from `payment_method + amount + currency` which is correct for the
     * 99% case but won't help if the same subscription is charged twice
     * for the same amount in the same minute. Subscription extension
     * callers pass an explicit key.
     *
     * @param string $payment_method      Stripe PaymentMethod ID (pm_xxx).
     * @param string $customer            Stripe Customer ID (cus_xxx).
     * @param float  $amount              Charge amount in major units (e.g. 99.95).
     * @param string $currency            ISO-4217 3-letter code.
     * @param array  $settings            Gateway settings (must include secret_key).
     * @param array  $metadata            Optional. Stripe metadata keys + optional
     *                                    `idempotency_key` (extracted before POST).
     *
     * @return array {
     *     success         bool    True if PI status is `succeeded`.
     *     transaction_id  string  PaymentIntent ID (pi_xxx) on success.
     *     charge_id       string  Latest charge ID (ch_xxx) on success.
     *     error           string  Stripe error message on failure.
     *     stripe_code     string  Stripe error code (e.g. `card_declined`,
     *                             `authentication_required`) on failure.
     *     intent          array   Full PI object for audit / writeback.
     * }
     */
    public function charge_off_session(
        string $payment_method,
        string $customer,
        float  $amount,
        string $currency,
        array  $settings,
        array  $metadata = []
    ): array {
        $secret_key = $this->setting( $settings, 'secret_key' );

        if ( ! $secret_key ) {
            return [ 'success' => false, 'error' => 'Stripe is not configured.' ];
        }

        if ( ! $payment_method || ! $customer ) {
            return [ 'success' => false, 'error' => 'Missing PaymentMethod or Customer ID for off-session charge.' ];
        }

        // Extract idempotency key from metadata (Stripe header, not body field).
        $idempotency_key = $metadata['idempotency_key'] ?? '';
        unset( $metadata['idempotency_key'] );

        $params = [
            'amount'         => (int) round( $amount * 100 ),
            'currency'       => strtolower( $currency ),
            'payment_method' => $payment_method,
            'customer'       => $customer,
            'confirm'        => 'true',
            'off_session'    => 'true',
        ];

        if ( ! empty( $metadata ) ) {
            $params['metadata'] = $metadata;
        }

        $response = $this->stripe_post(
            '/payment_intents',
            $secret_key,
            $params,
            $idempotency_key
        );

        if ( ! $response['ok'] ) {
            $error_body = $response['body']['error'] ?? [];
            return [
                'success'     => false,
                'error'       => $error_body['message'] ?? 'Stripe payment failed.',
                'stripe_code' => $error_body['code']    ?? '',
                'intent'      => $response['body'],
            ];
        }

        $intent = $response['body'];
        $status = $intent['status'] ?? '';

        if ( $status !== 'succeeded' ) {
            return [
                'success'     => false,
                'error'       => "PaymentIntent status: {$status}",
                'stripe_code' => $status,
                'intent'      => $intent,
            ];
        }

        return [
            'success'        => true,
            'transaction_id' => $intent['id'] ?? '',
            'charge_id'      => $intent['latest_charge'] ?? '',
            'intent'         => $intent,
        ];
    }

    // ══════════════════════════════════════════════════════════════════════════
    // WEBHOOK HANDLER
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Handle an inbound Stripe webhook request.
     *
     * Called by PaymentsModule when POST /titancart/v1/webhooks/stripe arrives.
     * Verifies the Stripe-Signature header, then dispatches the event.
     *
     * @param  \WP_REST_Request $request
     * @return \WP_REST_Response|\WP_Error
     */
    public function handle_webhook( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
        // Retrieve the raw body BEFORE WordPress decodes it.
        $payload        = $request->get_body();
        $sig_header     = $request->get_header( 'stripe_signature' );

        // We need the webhook_secret from the payment method settings.
        $webhook_secret = $this->get_webhook_secret();

        if ( ! $webhook_secret ) {
            // SECURITY: With no signing secret configured, the only thing protecting
            // this endpoint from forged events is… nothing. Because the PaymentIntent
            // id is returned to the checkout page, an attacker who scrapes one off
            // their own purchase page can POST a faked `payment_intent.succeeded`
            // for someone else's intent and mark that transaction completed.
            //
            // Production must set the webhook secret. For a local dev box that
            // genuinely needs to skip signature verification, opt in explicitly
            // by defining `TITANCART_ALLOW_UNSIGNED_WEBHOOKS` true in wp-config.php. The
            // constant is intentionally not a setting — it must be a code-level
            // decision that doesn't accidentally survive a database export.
            if ( ! defined( 'TITANCART_ALLOW_UNSIGNED_WEBHOOKS' ) || ! TITANCART_ALLOW_UNSIGNED_WEBHOOKS ) {
                titancart_log( 'TitanCart Stripe webhook: rejected unsigned event — no webhook_secret configured.' );
                return new \WP_Error(
                    'tc_webhook_unsigned',
                    'Stripe webhook signing secret not configured. See gateway settings.',
                    [ 'status' => 401 ]
                );
            }
            $event = json_decode( $payload, true );
        } else {
            $event = $this->verify_stripe_signature( $payload, $sig_header, $webhook_secret );
            if ( is_wp_error( $event ) ) {
                return $event;
            }
        }

        if ( empty( $event['type'] ) ) {
            return new \WP_Error( 'tc_invalid_event', 'Invalid event payload.', [ 'status' => 400 ] );
        }

        $this->dispatch_stripe_event( $event );

        return new \WP_REST_Response( [ 'received' => true ] );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PRIVATE — WEBHOOK HELPERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Dispatch a verified Stripe event to the correct handler.
     *
     * @param array $event
     */
    private function dispatch_stripe_event( array $event ): void {
        $object = $event['data']['object'] ?? [];

        switch ( $event['type'] ) {
            case 'payment_intent.succeeded':
                $this->on_payment_intent_succeeded( $object );
                break;

            case 'payment_intent.payment_failed':
                $this->on_payment_intent_failed( $object );
                break;

            case 'setup_intent.succeeded':
                // Vault-only ($0) orders confirm via a SetupIntent, not a
                // PaymentIntent (S279, B2). The synchronous finalize path also
                // calls handle_setup_intent_succeeded(); both are idempotent
                // (the completed-status guard short-circuits a repeat).
                $this->handle_setup_intent_succeeded( $object );
                break;

            case 'charge.refunded':
                // Handled via the refund() method; webhook is informational.
                break;

            default:
                // Unknown event — ignore silently.
                break;
        }
    }

    /**
     * Handle payment_intent.succeeded — mark transaction completed, advance order.
     *
     * @param array $intent  The PaymentIntent object from Stripe.
     */
    private function on_payment_intent_succeeded( array $intent ): void {
        $pi_id = $intent['id'] ?? '';
        if ( ! $pi_id ) {
            return;
        }

        $txn = titancart_db()->getRow(
            "SELECT * FROM {$this->db->table('payment_transactions')}
             WHERE transaction_id = ?s AND gateway = ?s
             ORDER BY id DESC LIMIT 1",
            $pi_id,
            self::SLUG
        );

        if ( ! $txn ) {
            titancart_log( "TitanCart Stripe webhook: no transaction found for PaymentIntent {$pi_id}" );
            return;
        }

        // Mark transaction completed AND refresh the gateway_response JSON
        // with the post-confirmation PI state (payment_method, customer,
        // latest_charge are all NULL on the pre-confirmation snapshot
        // captured at process_payment time — Bug #2 fix S213).
        $this->update_transaction_status( (int) $txn['id'], 'completed', $intent );

        // Advance order to processing.
        $this->set_order_status(
            (int) $txn['order_id'],
            'processing',
            __( 'Stripe payment confirmed.', 'titancart-lite' ),
            true
        );

        /**
         * Action: titancart_stripe_payment_intent_succeeded
         *
         * Fires after a Stripe PaymentIntent succeeds, the transaction row is
         * marked completed, and the order has been advanced to 'processing'.
         *
         * Used by the marketplace extension at B6 (S194+) to fire one Stripe
         * Transfer per vendor on multi-vendor orders, writing rows to
         * tc_marketplace_transfers and pushing vendor_share funds out to each
         * connected Express account via Pure SCT (Separate Charges and
         * Transfers) — the marketplace charge model locked at S192.
         *
         * Side-effect-only extensibility — use do_action, not apply_filters:
         *   Pattern #158 (filter hooks) covers data-shaping extension points
         *   (response/query/write/gateway-params/settings-fields). This hook
         *   is side-effect-only — listeners write their own rows, call their
         *   own external services, log their own errors; TC core has no need
         *   to read anything back. The standard TC core convention for that
         *   is `do_action` (29 instances across the codebase as of S194).
         *
         * Contract:
         *   - Listeners MUST NOT block or throw. The webhook handler returns
         *     200 to Stripe AFTER this fires; any uncaught exception causes
         *     Stripe to retry the webhook indefinitely.
         *   - Listeners SHOULD be idempotent. Stripe webhooks are at-least-
         *     once delivery; the same PI.succeeded event can arrive multiple
         *     times. Listeners that write rows MUST defend with a UNIQUE
         *     constraint or SELECT-first pattern.
         *   - Listeners SHOULD log errors but MUST NOT propagate them to TC
         *     core — error handling in the webhook context happens out of
         *     band (admin notifications, retry queues, etc).
         *   - $intent is the FULL Stripe PaymentIntent object as received
         *     from the webhook payload. Includes `latest_charge` (charge ID
         *     string), `amount`, `currency`, `metadata`, etc.
         *   - $txn is the corresponding row from tc_payment_transactions
         *     (gateway='stripe', transaction_id=$intent['id']).
         *   - $order_id is the integer order primary key (denormalized from
         *     $txn['order_id'] for convenience).
         *
         * Related hooks (gateway-pair, fires on the same PI lifecycle):
         *   - `titancart_stripe_payment_intent_params` (filter, S192) — fires
         *     when BUILDING the PI params at process_payment time. Used by
         *     marketplace to inject transfer_group. This action fires when
         *     the same PI succeeds, completing the round-trip.
         *
         * @since 0.14.0  S194 (Track B Module B6 — transfer-firing)
         *
         * @param array $intent    The Stripe PaymentIntent object (full payload).
         * @param array $txn       The matching tc_payment_transactions row.
         * @param int   $order_id  The order's primary key.
         */
        do_action(
            'titancart_stripe_payment_intent_succeeded',
            $intent,
            $txn,
            (int) $txn['order_id']
        );
    }

    /**
     * Handle payment_intent.payment_failed — mark transaction failed, update order.
     *
     * @param array $intent
     */
    private function on_payment_intent_failed( array $intent ): void {
        $pi_id  = $intent['id'] ?? '';
        $reason = $intent['last_payment_error']['message'] ?? __( 'Payment failed.', 'titancart-lite' );

        if ( ! $pi_id ) {
            return;
        }

        $txn = titancart_db()->getRow(
            "SELECT * FROM {$this->db->table('payment_transactions')}
             WHERE transaction_id = ?s AND gateway = ?s
             ORDER BY id DESC LIMIT 1",
            $pi_id,
            self::SLUG
        );

        if ( ! $txn ) {
            return;
        }

        $this->update_transaction_status( (int) $txn['id'], 'failed' );

        $this->set_order_status(
            (int) $txn['order_id'],
            'failed',
            /* translators: %s: failure reason reported by Stripe. */
            sprintf( __( 'Stripe payment failed: %s', 'titancart-lite' ), $reason ),
            false
        );
    }

    /**
     * Verify the Stripe-Signature header using the webhook secret.
     *
     * Implements Stripe's own verification algorithm so we do NOT require
     * the Stripe PHP SDK as a dependency.
     *
     * @param  string $payload     Raw request body.
     * @param  string $sig_header  Value of Stripe-Signature header.
     * @param  string $secret      Webhook signing secret.
     * @return array|\WP_Error     Decoded event array or WP_Error on failure.
     */
    private function verify_stripe_signature( string $payload, string $sig_header, string $secret ): array|\WP_Error {
        // Parse timestamp and signatures from header.
        $parts = [];
        foreach ( explode( ',', $sig_header ) as $part ) {
            [ $k, $v ] = array_pad( explode( '=', $part, 2 ), 2, '' );
            $parts[ trim( $k ) ][] = trim( $v );
        }

        $timestamp   = (int) ( $parts['t'][0] ?? 0 );
        $v1_sigs     = $parts['v1'] ?? [];

        if ( ! $timestamp || empty( $v1_sigs ) ) {
            return new \WP_Error( 'tc_stripe_sig_malformed', 'Malformed Stripe-Signature header.', [ 'status' => 400 ] );
        }

        // Reject if timestamp is older than 5 minutes (replay protection).
        if ( abs( time() - $timestamp ) > 300 ) {
            return new \WP_Error( 'tc_stripe_sig_stale', 'Stripe webhook timestamp too old.', [ 'status' => 400 ] );
        }

        $signed_payload = $timestamp . '.' . $payload;
        $expected_sig   = hash_hmac( 'sha256', $signed_payload, $secret );

        foreach ( $v1_sigs as $sig ) {
            if ( hash_equals( $expected_sig, $sig ) ) {
                $event = json_decode( $payload, true );
                return is_array( $event ) ? $event : [];
            }
        }

        return new \WP_Error( 'tc_stripe_sig_invalid', 'Stripe signature verification failed.', [ 'status' => 403 ] );
    }

    /**
     * Retrieve the webhook_secret from the active Stripe payment method settings.
     *
     * @return string Empty string if not configured.
     */
    private function get_webhook_secret(): string {
        $row = $this->db->getRow(
            "SELECT settings FROM {$this->db->table('payment_methods')}
             WHERE gateway = ?s AND status = 'active' LIMIT 1",
            self::SLUG
        );

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

        $settings = json_decode( $row['settings'] ?? '{}', true );
        return $settings['webhook_secret'] ?? '';
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PRIVATE — STRIPE HTTP HELPERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * POST to the Stripe API using form-encoded body (Stripe's preferred format).
     *
     * @param  string $endpoint         Path relative to API base (e.g. '/payment_intents').
     * @param  string $secret           Stripe secret key.
     * @param  array  $params           Body parameters.
     * @param  string $idempotency_key  Optional. Stripe Idempotency-Key header
     *                                  value — guarantees a duplicate request
     *                                  (within 24h) returns the original response
     *                                  instead of creating a second charge.
     *                                  Required for safe Pattern #181 renewal
     *                                  retries; not used by checkout flow.
     * @return array{ok: bool, status: int, body: array, raw: string}
     */
    private function stripe_post( string $endpoint, string $secret, array $params, string $idempotency_key = '' ): array {
        $headers = [
            'Authorization'  => 'Bearer ' . $secret,
            'Content-Type'   => 'application/x-www-form-urlencoded',
            'Stripe-Version' => '2023-10-16',
        ];

        if ( $idempotency_key !== '' ) {
            $headers['Idempotency-Key'] = $idempotency_key;
        }

        $response = wp_remote_post( self::API_BASE . $endpoint, [
            'headers' => $headers,
            'body'    => $this->build_stripe_body( $params ),
            'timeout' => 20,
        ] );

        if ( is_wp_error( $response ) ) {
            return [ 'ok' => false, 'status' => 0, 'body' => [], 'raw' => '' ];
        }

        $status = (int) wp_remote_retrieve_response_code( $response );
        $raw    = wp_remote_retrieve_body( $response );
        $body   = json_decode( $raw, true ) ?: [];

        return [ 'ok' => $status >= 200 && $status < 300, 'status' => $status, 'body' => $body, 'raw' => $raw ];
    }

    /**
     * GET from the Stripe API.
     *
     * @param  string $endpoint
     * @param  string $secret
     * @return array{ok: bool, status: int, body: array, raw: string}
     */
    private function stripe_get( string $endpoint, string $secret ): array {
        $response = wp_remote_get( self::API_BASE . $endpoint, [
            'headers' => [
                'Authorization' => 'Bearer ' . $secret,
                'Stripe-Version'=> '2023-10-16',
            ],
            'timeout' => 20,
        ] );

        if ( is_wp_error( $response ) ) {
            return [ 'ok' => false, 'status' => 0, 'body' => [], 'raw' => '' ];
        }

        $status = (int) wp_remote_retrieve_response_code( $response );
        $raw    = wp_remote_retrieve_body( $response );
        $body   = json_decode( $raw, true ) ?: [];

        return [ 'ok' => $status >= 200 && $status < 300, 'status' => $status, 'body' => $body, 'raw' => $raw ];
    }

    /**
     * Recursively build a Stripe-compatible form-encoded body.
     * Stripe uses `metadata[key]=value` style nesting.
     *
     * @param  array  $params
     * @param  string $prefix
     * @return string
     */
    private function build_stripe_body( array $params, string $prefix = '' ): string {
        $parts = [];
        foreach ( $params as $key => $value ) {
            $full_key = $prefix ? "{$prefix}[{$key}]" : (string) $key;
            if ( is_array( $value ) ) {
                $parts[] = $this->build_stripe_body( $value, $full_key );
            } else {
                $parts[] = urlencode( $full_key ) . '=' . urlencode( (string) $value );
            }
        }
        return implode( '&', $parts );
    }
}
