<?php
/**
 * Payments Module — bootstrapper.
 *
 * Responsibilities:
 *   1. Register all built-in gateways into GatewayRegistry
 *   2. Fire `titancart_register_gateways` so third-party code can add more
 *   3. Register REST routes (PaymentsController + raw webhook endpoints)
 *   4. Hook into `titancart_order_placed` to process payment immediately
 *   5. Hook into `titancart_refund_issued` to reconcile order + charge status
 *      after a successful gateway refund
 *   6. Hook into `titancart_payment_surcharge` filter (CheckoutManager)
 *   8. Seed default COD payment method on first activation
 *
 * @package TitanCart\Modules\Payments
 */

namespace TitanCart\Modules\Payments;

use TitanCart\Modules\Payments\Gateways\StripeGateway;
use TitanCart\Modules\Payments\Gateways\CodGateway;
use TitanCart\Modules\Payments\Gateways\BankTransferGateway;
use TitanCart\Modules\Payments\Gateways\CheckGateway;
use TitanCart\REST\Payments\PaymentsController;
use TitanCart\Modules\Orders\OrderManager;
use TitanCart\Core\LogService;

defined( 'ABSPATH' ) || exit;

class PaymentsModule {

    /**
     * How long to suppress a retry after a FAILED default-seed attempt (seconds).
     *
     * KI-83: `maybe_seed_defaults()` runs on `titancart_loaded` — i.e. on every
     * request — so a failed seed must not retry immediately (that would re-attempt
     * an INSERT on every page load forever), nor be recorded as permanently done
     * (that is the defect: the store ends up with zero payment methods, silently,
     * forever). A short transient gives bounded retry.
     */
    private const SEED_RETRY_DELAY = 5 * MINUTE_IN_SECONDS;

    /**
     * Initialise the Payments module. Called from Plugin::boot() before
     * `titancart_loaded` fires.
     */
    public static function init(): void {
        $instance = new self();

        // 1. Register built-in gateways (and let third-parties register theirs).
        add_action( 'init', [ $instance, 'register_gateways' ], 5 );

        // 2. REST routes.
        add_action( 'rest_api_init', [ $instance, 'register_routes' ] );

        // 3. Process payment immediately after order is placed.
        add_action( 'titancart_order_placed', [ $instance, 'process_payment_for_order' ], 10, 3 );

        // 4. Reconcile order + charge transaction state after a successful refund.
        //    Fires from PaymentsController::issue_refund() once the gateway returns
        //    success. Idempotent — offline gateways already set status inside their
        //    refund() method, so this short-circuits when status is already 'refunded'.
        add_action( 'titancart_refund_issued', [ $instance, 'handle_refund_issued' ], 10, 4 );

        // 5. Inject the real payment surcharge into the checkout totals.
        add_filter( 'titancart_payment_surcharge', [ $instance, 'calculate_surcharge' ], 10, 3 );


        // 7. Inject payment data into the place-order REST response.
        add_filter( 'titancart_place_order_response', [ $instance, 'inject_payment_data' ], 10, 2 );

        // 8. Seed default payment methods on activation (transient-guarded).
        add_action( 'titancart_loaded', [ $instance, 'maybe_seed_defaults' ] );

        // 9. Pattern #69 (S134) — surface gateway misconfigurations that
        //    cause silent breakage (e.g. Stripe enabled with empty
        //    webhook_secret post-S133 fail-closed regression).
        add_action( 'admin_notices', [ $instance, 'gateway_config_notices' ] );

        // 10. Render post-checkout payment instructions (bank account details,
        //     check mailing address) on the order-received + account order-detail
        //     pages. Online gateways and COD render nothing. S210 fix — Task 4b
        //     smoke caught customers couldn't see bank details after checkout.
        add_action( 'titancart_order_received_payment_section', [ $instance, 'render_post_checkout_instructions' ], 10, 1 );
        add_action( 'titancart_order_detail_payment_section',   [ $instance, 'render_post_checkout_instructions' ], 10, 1 );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // GATEWAY REGISTRY
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Register built-in gateways, then fire the extension action.
     */
    public function register_gateways(): void {
        $registry = GatewayRegistry::instance();

        $registry->register( StripeGateway::SLUG,       StripeGateway::class );
        $registry->register( CodGateway::SLUG,          CodGateway::class );
        $registry->register( BankTransferGateway::SLUG, BankTransferGateway::class );
        $registry->register( CheckGateway::SLUG,        CheckGateway::class );

        /**
         * Third-party gateways hook here:
         *   add_action( 'titancart_register_gateways', fn($r) => $r->register('mygateway', MyGateway::class) );
         */
        do_action( 'titancart_register_gateways', $registry );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // REST ROUTES
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Register the PaymentsController routes and the raw webhook endpoints.
     */
    public function register_routes(): void {
        // Admin + storefront payment routes.
        ( new PaymentsController() )->register_routes();

        // Stripe webhook — must be registered separately so WP does NOT
        // apply cookie auth or nonce checks (Stripe sends no cookies).
        register_rest_route( 'titancart/v1', '/webhooks/stripe', [
            [
                'methods'             => \WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'stripe_webhook' ],
                'permission_callback' => '__return_true', // Public by design: provider webhook carries no WP cookies — auth = Stripe signature verification inside the handler.
            ],
        ] );

    }

    // ══════════════════════════════════════════════════════════════════════════
    // PAYMENT PROCESSING
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Called on `titancart_order_placed`.
     *
     * Resolves the gateway from the payment_method row, calls process_payment(),
     * and stores the result as transient keyed by order_id so the REST response
     * filter can retrieve it.
     *
     * @param  int   $order_id
     * @param  array $order_data  Full order row.
     * @param  array $totals      Cart totals array.
     */
    public function process_payment_for_order( int $order_id, array $order_data, array $totals ): void {
        $payment_method_id = (int) ( $order_data['payment_method_id'] ?? 0 );

        // Retrieve gateway slug + settings.
        $repo   = new PaymentMethodRepository();
        $method = $repo->get_by_id( $payment_method_id );

        if ( ! $method ) {
            LogService::error( 'payments', 'error', 'Payment method #' . $payment_method_id . ' not found for order #' . $order_id, [
                'object_type'       => 'order',
                'object_id'         => $order_id,
                'payment_method_id' => $payment_method_id,
            ] );
            return;
        }

        $gateway_slug = $method['gateway'] ?? '';
        $registry     = GatewayRegistry::instance();

        if ( ! $registry->has( $gateway_slug ) ) {
            LogService::error( 'payments', 'error', 'Gateway "' . $gateway_slug . '" not registered for order #' . $order_id, [
                'object_type'       => 'order',
                'object_id'         => $order_id,
                'gateway'           => $gateway_slug,
                'payment_method_id' => $payment_method_id,
            ] );
            return;
        }

        $gateway  = $registry->make( $gateway_slug );
        $settings = $method['settings'] ?? [];

        // Merge order with totals so gateways have all the data they need.
        $full_order = array_merge( $order_data, [
            'total'             => $totals['total'] ?? $order_data['total'] ?? 0,
            'subtotal'          => $totals['subtotal'] ?? 0,
            'tax_total'         => $totals['tax_total'] ?? 0,
            'shipping_cost'     => $totals['shipping_cost'] ?? 0,
            'discount'          => $totals['discount'] ?? 0,
            'payment_surcharge' => $totals['payment_surcharge'] ?? 0,
            'payment_method_id' => $payment_method_id,
        ] );

        $result = $gateway->process_payment( $order_id, $full_order, $settings );

        // Log payment result.
        if ( ! empty( $result['success'] ) ) {
            LogService::info( 'payments', 'success', 'Payment successful for order #' . $order_id . ' via ' . $gateway_slug, [
                'object_type'    => 'order',
                'object_id'      => $order_id,
                'gateway'        => $gateway_slug,
                'transaction_id' => $result['transaction_id'] ?? '',
                'amount'         => $full_order['total'],
            ] );
        } else {
            LogService::error( 'payments', 'failed', 'Payment failed for order #' . $order_id . ' via ' . $gateway_slug . ': ' . ( $result['message'] ?? 'unknown' ), [
                'object_type'    => 'order',
                'object_id'      => $order_id,
                'gateway'        => $gateway_slug,
                'error_message'  => $result['message'] ?? '',
                'amount'         => $full_order['total'],
                'backtrace'      => true,
            ] );
        }

        /**
         * Fires after a payment has been processed (successfully or not).
         *
         * @param array $result    { success, transaction_id, message, payment_data }
         * @param int   $order_id
         * @param array $full_order
         */
        do_action( 'titancart_payment_processed', $result, $order_id, $full_order );

        // Stash result for 5 minutes so the REST filter can attach payment_data to the response.
        set_transient( "titancart_payment_result_{$order_id}", $result, 5 * MINUTE_IN_SECONDS );
    }

    /**
     * Filter: `titancart_place_order_response`
     *
     * Merges gateway payment_data into the place-order REST response body.
     * This is how Stripe's client_secret and PayPal's redirect_url reach the JS.
     *
     * @param  array $response  The current response array from CheckoutManager::place_order().
     * @param  int   $order_id
     * @return array
     */
    public function inject_payment_data( array $response, int $order_id ): array {
        $result = get_transient( "titancart_payment_result_{$order_id}" );

        if ( ! $result ) {
            return $response;
        }

        // If payment failed, override the success flag.
        if ( ! $result['success'] ) {
            $response['success'] = false;
            $response['message'] = $result['message'];
        }

        // Merge any gateway-specific data (client_secret, redirect_url, etc.).
        $response['payment_data'] = $result['payment_data'] ?? [];

        // Clean up transient.
        delete_transient( "titancart_payment_result_{$order_id}" );

        return $response;
    }

    // ══════════════════════════════════════════════════════════════════════════
    // POST-CHECKOUT INSTRUCTIONS RENDERING
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Listener for `titancart_order_received_payment_section` and
     * `titancart_order_detail_payment_section`. Looks up the payment method
     * used on the order, instantiates its gateway, calls
     * `render_post_checkout_instructions()`, and echoes the result wrapped in
     * a TitanCart card.
     *
     * Online gateways and COD return empty strings — we short-circuit before
     * emitting any wrapper markup so the page stays clean.
     *
     * Auth: this method is only invoked from authenticated views — the
     * order-received template uses constant-time `order_key` comparison and
     * the account order-detail page requires the user to own the order. So
     * emitting bank account details here is safe; we deliberately do NOT
     * include this content in confirmation emails for the same reason.
     *
     * @param  array $order  Full order row from tc_orders (must contain
     *                       `id`, `payment_method_id`).
     * @return void          Echoes HTML directly.
     */
    public function render_post_checkout_instructions( $order ): void {
        if ( ! is_array( $order ) ) {
            return;
        }
        $method_id = (int) ( $order['payment_method_id'] ?? 0 );
        if ( $method_id <= 0 ) {
            return;
        }

        $db = titancart_db();
        $method = $db->getRow(
            "SELECT gateway, settings FROM {$db->table('payment_methods')} WHERE id = ?i AND status = 'active'",
            $method_id
        );
        if ( ! $method || empty( $method['gateway'] ) ) {
            return;
        }

        $gateway = GatewayRegistry::instance()->make( (string) $method['gateway'] );
        if ( ! $gateway ) {
            return;
        }

        // Decode JSON settings (column type is JSON; some MySQL drivers return
        // string, others return an already-decoded array).
        $settings_raw = $method['settings'] ?? null;
        if ( is_string( $settings_raw ) ) {
            $settings = json_decode( $settings_raw, true );
        } else {
            $settings = $settings_raw;
        }
        if ( ! is_array( $settings ) ) {
            $settings = [];
        }

        $html = $gateway->render_post_checkout_instructions( $order, $settings );
        if ( ! is_string( $html ) || trim( $html ) === '' ) {
            return;
        }

        // Filterable wrapper + heading so themes/extensions can swap their own
        // card chrome in. Marketplace registers a listener in MarketplacePlugin
        // that substitutes `tcm-card ...` classes so the instructions card
        // visually matches the surrounding "Thank you" / "Your order" cards.
        $wrapper_class = (string) apply_filters(
            'titancart_payment_instructions_wrapper_class',
            'tc-payment-instructions-card'
        );
        $heading_class = (string) apply_filters(
            'titancart_payment_instructions_heading_class',
            'tc-payment-instructions-card__title'
        );
        $heading_tag = (string) apply_filters(
            'titancart_payment_instructions_heading_tag',
            'h3'
        );
        // Defensive — only allow h1-h6 from the filter (prevents arbitrary
        // tag injection if a filter callback misbehaves).
        if ( ! preg_match( '/^h[1-6]$/', $heading_tag ) ) {
            $heading_tag = 'h3';
        }

        echo '<div class="' . esc_attr( $wrapper_class ) . '">';
        echo '<' . tag_escape( $heading_tag ) . ' class="' . esc_attr( $heading_class ) . '">'
            . esc_html__( 'Payment Instructions', 'titancart-lite' )
            . '</' . tag_escape( $heading_tag ) . '>';
        // WP.org R2 item 6: kses with an explicit gateway-surface allowlist at
        // the echo (structural containers, form controls incl. select/option,
        // buttons, and gateway SDK iframes, plus data-* attrs). Each gateway's
        // build_instructions() still escapes its own user-supplied settings.
        $tc_gw_common = [
            'id'    => true,
            'class' => true,
            'style' => true,
            'data-*' => true,
        ];
        echo wp_kses( $html, [
            'div'    => $tc_gw_common,
            'span'   => $tc_gw_common,
            'p'      => $tc_gw_common,
            'strong' => $tc_gw_common,
            'em'     => $tc_gw_common,
            'br'     => [],
            'a'      => $tc_gw_common + [ 'href' => true, 'target' => true, 'rel' => true ],
            'label'  => $tc_gw_common + [ 'for' => true ],
            'input'  => $tc_gw_common + [ 'type' => true, 'name' => true, 'value' => true, 'placeholder' => true, 'required' => true, 'checked' => true, 'readonly' => true, 'disabled' => true, 'autocomplete' => true, 'maxlength' => true, 'inputmode' => true, 'pattern' => true ],
            'select' => $tc_gw_common + [ 'name' => true, 'required' => true, 'disabled' => true ],
            'option' => $tc_gw_common + [ 'value' => true, 'selected' => true, 'disabled' => true ],
            'button' => $tc_gw_common + [ 'type' => true, 'name' => true, 'value' => true, 'disabled' => true ],
            'iframe' => $tc_gw_common + [ 'src' => true, 'title' => true, 'width' => true, 'height' => true, 'frameborder' => true, 'allow' => true, 'allowtransparency' => true, 'scrolling' => true ],
        ] );
        echo '</div>';
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PAYMENT SURCHARGE FILTER
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Filter: `titancart_payment_surcharge`
     *
     * Replaces the 0.0 default with the real surcharge calculated from
     * the payment_methods table.
     *
     * @param  float $default          Always 0.0 (the filter's initial value).
     * @param  int   $payment_method_id
     * @param  array $cart             Cart data array.
     * @return float
     */
    public function calculate_surcharge( float $default, int $payment_method_id, array $cart ): float {
        if ( $payment_method_id <= 0 ) {
            return 0.0;
        }

        $subtotal = (float) ( $cart['subtotal'] ?? 0 );
        $repo     = new PaymentMethodRepository();

        return $repo->calculate_surcharge( $payment_method_id, $subtotal );
    }


    // ══════════════════════════════════════════════════════════════════════════
    // STRIPE WEBHOOK
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * REST endpoint: POST /titancart/v1/webhooks/stripe
     *
     * Delegates to StripeGateway::handle_webhook() which verifies the
     * signature and dispatches the event.
     *
     * @param  \WP_REST_Request $request
     * @return \WP_REST_Response|\WP_Error
     */
    public function stripe_webhook( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
        $gateway = new StripeGateway();
        return $gateway->handle_webhook( $request );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // DEFAULT SEEDING
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Seed one default COD payment method if no methods exist yet.
     * Runs once per site; transient-guarded to prevent duplicate rows.
     */
    public function maybe_seed_defaults(): void {
        if ( get_transient( 'titancart_payments_seeded' ) ) {
            return;
        }

        $db    = titancart_db();
        $count = (int) $db->getField( "SELECT COUNT(*) FROM {$db->table('payment_methods')}" );

        if ( $count > 0 ) {
            set_transient( 'titancart_payments_seeded', 1, 0 ); // persist indefinitely
            return;
        }

        $repo = new PaymentMethodRepository();

        // Seed COD as the default out-of-the-box method.
        $id = $repo->create(
            [
                'gateway'        => CodGateway::SLUG,
                'position'       => 1,
                'surcharge'      => 0,
                'surcharge_type' => 'A',
                'settings'       => [ 'order_status' => 'on_hold' ],
                'status'         => 'active',
                'test_mode'      => 0,
            ],
            [
                'en' => [
                    'name'         => __( 'Cash on Delivery', 'titancart-lite' ),
                    'description'  => __( 'Pay with cash when your order is delivered.', 'titancart-lite' ),
                    'instructions' => __( 'Please have the exact amount ready.', 'titancart-lite' ),
                ],
            ]
        );

        // KI-83: the guard must record THE WORK, not merely that we reached the end
        // of the function. Setting it unconditionally makes a failed seed
        // byte-identical to a successful one, and it is never retried.
        if ( $id > 0 ) {
            set_transient( 'titancart_payments_seeded', 1, 0 ); // persist indefinitely — seeded
            return;
        }

        // Failure: retry on a later load, but BOUNDED. This hook runs on
        // `titancart_loaded` (every request), so "only set on success" would
        // re-attempt the INSERT on every page load forever.
        set_transient( 'titancart_payments_seeded', 1, self::SEED_RETRY_DELAY );

        LogService::error(
            'general',
            'seed',
            'Failed to seed the default COD payment method; will retry shortly.',
            [ 'object_type' => 'payment_method', 'retry_in' => self::SEED_RETRY_DELAY ]
        );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // REFUND RECONCILIATION
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Reconcile order + charge transaction state after a gateway refund.
     *
     * Fires from `titancart_refund_issued` (dispatched by
     * PaymentsController::issue_refund() once a gateway returns a successful
     * refund response). The gateway's own refund() method has already inserted
     * the refund row into tc_payment_transactions via log_transaction() — this
     * listener's job is limited to:
     *
     *   1. Sum all completed refund txn rows for the order.
     *   2. If the total equals (or exceeds) the order total, flip the order
     *      status to 'refunded', log a note, and mark the original charge
     *      txn as 'refunded' so get_latest_charge() stops finding it (prevents
     *      accidental double-refunds).
     *   3. If the total is still less than the order total, keep the order
     *      status unchanged and add a system note recording the partial refund.
     *      (There is no 'partial_refunded' status handle — partials are an
     *      adjustment, not a state change, mirroring Stripe/Square/Woo UX.)
     *
     * Idempotent: offline gateways (COD, Check, BankTransfer) set the order
     * status to 'refunded' inside their own refund() method, so this listener
     * short-circuits when it sees that status is already 'refunded'.
     *
     * RMA-initiated refunds are NOT routed through this listener — RmaManager
     * calls $gateway->refund() directly without firing this action.
     *
     * @param int    $order_id   Order ID being refunded.
     * @param float  $amount     Amount of THIS refund (not the running total).
     * @param string $refund_id  Gateway refund ID (for audit trail).
     * @param array  $result     Raw gateway refund result (reserved for future use).
     * @return void
     */
    public function handle_refund_issued( int $order_id, float $amount, string $refund_id, array $result ): void {
        $db = titancart_db();

        // Load the order with its current status handle.
        $order = $db->getRow(
            "SELECT o.id, o.total, os.handle AS status_handle
             FROM {$db->table('orders')} o
             LEFT JOIN {$db->table('order_statuses')} os ON os.id = o.status_id
             WHERE o.id = ?i LIMIT 1",
            $order_id
        );

        if ( ! $order ) {
            return;
        }

        // Offline gateways flip the status themselves — short-circuit to avoid
        // double-noting and redundant state changes.
        if ( ( $order['status_handle'] ?? '' ) === 'refunded' ) {
            return;
        }

        // Sum all completed refund amounts for this order (includes the row
        // the gateway just inserted via log_transaction()).
        $total_refunded = (float) $db->getField(
            "SELECT COALESCE(SUM(amount), 0)
             FROM {$db->table('payment_transactions')}
             WHERE order_id = ?i AND type = 'refund' AND status = 'completed'",
            $order_id
        );

        $order_total = (float) ( $order['total'] ?? 0 );

        // Small epsilon guards against DECIMAL(12,4) rounding at boundary.
        $is_full_refund = ( $order_total > 0 )
            && ( ( $total_refunded + 0.001 ) >= $order_total );

        $order_manager = new OrderManager();

        if ( $is_full_refund ) {
            // Full refund path: flip order → 'refunded', mark charge → 'refunded'.
            $order_manager->update_status(
                $order_id,
                'refunded',
                sprintf(
                    /* translators: %s: gateway refund ID */
                    __( 'Full refund completed (refund ID %s).', 'titancart-lite' ),
                    $refund_id
                ),
                true    // Notify customer of the status change.
            );

            // Mark the original charge transaction so get_latest_charge() no
            // longer returns it — prevents stacking refunds against an order
            // that has already been fully refunded.
            $charge = $db->getRow(
                "SELECT id FROM {$db->table('payment_transactions')}
                 WHERE order_id = ?i AND type = 'charge' AND status = 'completed'
                 ORDER BY id DESC LIMIT 1",
                $order_id
            );

            if ( $charge ) {
                $db->update(
                    'payment_transactions',
                    [ 'status' => 'refunded' ],
                    [ 'id' => (int) $charge['id'] ]
                );
            }
        } else {
            // Partial refund path: leave order status alone, add a system note
            // summarising the running refund total. Multiple partials that
            // eventually equal the order total will flip the status on the
            // final refund via the full-refund branch above.
            $order_manager->add_note(
                $order_id,
                sprintf(
                    /* translators: 1: this refund amount, 2: gateway refund id, 3: running refund total, 4: order total */
                    __( 'Partial refund of %1$s processed (refund ID %2$s). Total refunded to date: %3$s of %4$s.', 'titancart-lite' ),
                    number_format( $amount, 2 ),
                    $refund_id,
                    number_format( $total_refunded, 2 ),
                    number_format( $order_total, 2 )
                ),
                'system'
            );
        }
    }

    // ══════════════════════════════════════════════════════════════════════════
    // ADMIN NOTICES — gateway misconfiguration surfacing (Pattern #69, S135)
    // ══════════════════════════════════════════════════════════════════════════
    //
    // S133 shipped fail-closed Stripe webhook handling (Pattern #64): an empty
    // webhook_secret causes the handler to refuse events with HTTP 401. The
    // hardening was correct, but merchants who upgraded with an empty secret
    // (the field had been nominally optional pre-S133) hit silent payment-stall
    // regression — Stripe payments stuck at "Pending" with no UI signal pointing
    // at the cause. Pattern #69 (S134) prescribes admin-UI surfacing whenever a
    // security flip moves a setting from default-permissive to default-deny.
    //
    // Currently audits Stripe only — PayPal/Square use sync return-to-site
    // capture with no webhook surface; AN declares a signature_key field but
    // has no handle_webhook consumer (separate cleanup).

    /**
     * Render WP admin_notices for any active gateway with a known fail-closed
     * misconfiguration. Gated to TitanCart admin pages so the notice doesn't
     * pollute unrelated WordPress screens.
     */
    public function gateway_config_notices(): void {
        // Gate: only show on TitanCart admin pages. The hash-routed sub-pages
        // (#/payments, #/orders, etc.) all share the same WP-side page slug,
        // so this single screen check matches every TC admin route.
        $screen = get_current_screen();
        if ( ! $screen || $screen->id !== 'toplevel_page_titancart' ) {
            return;
        }

        // Permissions: only show to admins who can act on the fix.
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }

        $issues = $this->collect_gateway_config_issues();
        if ( empty( $issues ) ) {
            return;
        }

        $payments_url = admin_url( 'admin.php?page=titancart#/payments' ); // Raw; escaped at output below.

        foreach ( $issues as $issue ) {
            printf(
                '<div class="notice notice-error"><p><strong>%s</strong> %s <a href="%s">%s</a></p></div>',
                esc_html( $issue['title'] ),
                esc_html( $issue['body'] ),
                esc_url( $payments_url ),
                esc_html__( 'Open Payment Gateways →', 'titancart-lite' )
            );
        }
    }

    /**
     * Inspect each enabled gateway for known fail-closed misconfigurations.
     *
     * Add new gateways here as additional fail-closed surfaces ship.
     *
     * @return array<int, array{title: string, body: string}>
     */
    private function collect_gateway_config_issues(): array {
        $issues = [];

        // ── Stripe: webhook_secret required (Pattern #64 fail-closed, S133) ──
        if ( $this->stripe_webhook_secret_missing() ) {
            $issues[] = [
                'title' => __( 'Stripe gateway: webhook signing secret missing.', 'titancart-lite' ),
                'body'  => __( 'Stripe payments will not advance past "Pending" until the signing secret is configured. Find it in Stripe Dashboard → Developers → Webhooks → click your endpoint → Signing secret (starts with whsec_). Paste it into the Stripe gateway settings.', 'titancart-lite' ),
            ];
        }

        return $issues;
    }

    /**
     * True iff Stripe gateway is active, webhook_secret is empty, and the
     * dev-override constant TITANCART_ALLOW_UNSIGNED_WEBHOOKS is not set.
     *
     * Mirrors the gating logic in StripeGateway::handle_webhook so the notice
     * appears exactly when payments would silently break.
     */
    private function stripe_webhook_secret_missing(): bool {
        // Dev override active → unsigned webhooks accepted → no warning needed.
        if ( defined( 'TITANCART_ALLOW_UNSIGNED_WEBHOOKS' ) && TITANCART_ALLOW_UNSIGNED_WEBHOOKS ) {
            return false;
        }

        $db = titancart_db();
        if ( ! $db ) {
            return false;
        }

        // Defensive: table may not exist on first activation.
        try {
            $row = $db->getRow(
                "SELECT settings FROM {$db->table('payment_methods')}
                 WHERE gateway = ?s AND status = 'active' LIMIT 1",
                'stripe'
            );
        } catch ( \Throwable $e ) {
            return false;
        }

        if ( ! $row ) {
            // Stripe not active — no warning.
            return false;
        }

        $settings = json_decode( $row['settings'] ?? '{}', true );
        if ( ! is_array( $settings ) ) {
            $settings = [];
        }

        return empty( $settings['webhook_secret'] );
    }
}
