<?php
/**
 * Payments REST API Controller.
 *
 * Endpoints:
 *   GET    /titancart/v1/payment-methods              List all payment methods (admin)
 *   POST   /titancart/v1/payment-methods              Create a payment method (admin)
 *   GET    /titancart/v1/payment-methods/{id}         Get single method (admin)
 *   PUT    /titancart/v1/payment-methods/{id}         Update a method (admin)
 *   DELETE /titancart/v1/payment-methods/{id}         Disable a method (admin, soft-delete)
 *   GET    /titancart/v1/payment-methods/{id}/fields  Get settings fields for a gateway (admin)
 *
 *   GET    /titancart/v1/checkout/payment-methods     Active methods for checkout (public)
 *
 *   POST   /titancart/v1/orders/{id}/refund           Issue a refund (admin)
 *   GET    /titancart/v1/orders/{id}/transactions     List transactions for an order (admin)
 *
 * @package TitanCart\REST\Payments
 */

namespace TitanCart\REST\Payments;

use TitanCart\Modules\Payments\PaymentMethodRepository;
use TitanCart\Modules\Payments\TransactionRepository;
use TitanCart\Modules\Payments\GatewayRegistry;
use TitanCart\Core\LogService;
use TitanCart\Core\Secrets;
use WP_REST_Controller;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;

defined( 'ABSPATH' ) || exit;

class PaymentsController extends WP_REST_Controller {

    protected $namespace = 'titancart/v1';
    protected $rest_base = 'payment-methods';

    /**
     * Payment gateways available in the Lite edition.
     *
     * Stripe + the three offline gateways cover the baseline "take a payment"
     * need. Every OTHER registered gateway (PayPal, Authorize.net, Square, and
     * any third-party gateway) is a Pro capability (the advanced-gateways cap
     * in Edition.php): it cannot be created, modified/activated, or offered at
     * checkout on a Lite install. Fail-closed — an unrecognised/new gateway is
     * treated as Pro.
     */
    private const LITE_GATEWAYS = [ 'stripe', 'cod', 'bank_transfer', 'check' ];

    private PaymentMethodRepository $repo;
    private TransactionRepository   $txn_repo;

    public function __construct() {
        $this->repo     = new PaymentMethodRepository();
        $this->txn_repo = new TransactionRepository();
    }

    public function register_routes(): void {

        // ── Payment method CRUD (admin) ───────────────────────────────────────

        register_rest_route( $this->namespace, '/' . $this->rest_base, [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_methods' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'create_method' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>\d+)', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_method' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'update_method' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::DELETABLE,
                // KI-97 (S424): was `disable_method`, a soft-disable behind a
                // confirm that said "Delete payment gateway". Now a real delete
                // that backfills order history first.
                'callback'            => [ $this, 'delete_method' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // Return gateway settings field definitions so the React admin UI can
        // render the correct inputs for any gateway.
        register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>\d+)/fields', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_gateway_fields' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // List all registered gateway types (for the "Add" form dropdown).
        register_rest_route( $this->namespace, '/payment-gateways', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_available_gateways' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // Return gateway fields by slug (no saved method needed — for new method form).
        register_rest_route( $this->namespace, '/payment-gateways/(?P<slug>[a-z_]+)/fields', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_gateway_fields_by_slug' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // Test gateway connection (validates API credentials without creating a real charge).
        register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>\d+)/test', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'test_connection' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // ── Active methods for checkout (public) ─────────────────────────────
        // WP.org R1 🅱4: public by design — the checkout page lists enabled
        // payment methods for guests. Read-only roster (name/slug/instructions);
        // credentials are masked before any method row leaves the API.
        register_rest_route( $this->namespace, '/checkout/payment-methods', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_checkout_methods' ],
                'permission_callback' => '__return_true', // Public by design: guest checkout — returns the enabled payment methods the checkout page already renders.
            ],
        ] );

        // ── Refunds (admin) ───────────────────────────────────────────────────
        register_rest_route( $this->namespace, '/orders/(?P<id>\d+)/refund', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'issue_refund' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // ── Transaction log (admin) ───────────────────────────────────────────
        register_rest_route( $this->namespace, '/orders/(?P<id>\d+)/transactions', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_transactions' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // HANDLERS — PAYMENT METHODS
    // ══════════════════════════════════════════════════════════════════════════

    // ══════════════════════════════════════════════════════════════════════════
    // GATEWAY CREDENTIAL MASKING (KI-96 sibling fix, S430)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Replace stored password-type gateway settings with the shared mask
     * before a method row leaves the API.
     *
     * The field list comes from the gateway's OWN schema: any settings field
     * declared `type => password` is a credential. Empty values are NOT
     * masked — masking an empty value would claim a credential exists, and
     * the write-side merge in update_method() would then "preserve" that
     * nothing forever.
     *
     * ⚠️ Applied at the REST boundary ONLY (see \TitanCart\Core\Secrets):
     * test_connection() and issue_refund() read the REAL settings to sign
     * live gateway calls and must never be handed a masked row.
     *
     * S430: extracted from get_method() so the create (201) and update (200)
     * echoes share it — both previously returned get_by_id() UNMASKED,
     * echoing Stripe/PayPal secrets back on every save (the exact leak
     * closed at Shipping's four sites in S429).
     *
     * @param  mixed $method  Method row; anything else passes through untouched.
     * @return mixed
     */
    private function mask_gateway_secrets( mixed $method ): mixed {
        if ( ! is_array( $method ) || empty( $method['settings'] ) || ! is_array( $method['settings'] ) ) {
            return $method;
        }

        $registry = GatewayRegistry::instance();
        $gateway  = $registry->make( $method['gateway'] ?? '' );

        if ( ! $gateway ) {
            return $method;
        }

        foreach ( $gateway->get_settings_fields() as $field ) {
            $key = (string) ( $field['key'] ?? '' );
            if ( '' !== $key && ( $field['type'] ?? '' ) === 'password' && ! empty( $method['settings'][ $key ] ) ) {
                $method['settings'][ $key ] = Secrets::MASK;
            }
        }

        return $method;
    }

    /** GET /payment-methods */
    public function get_methods( WP_REST_Request $request ): WP_REST_Response {
        $lang   = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );
        $status = sanitize_key( $request->get_param( 'status' ) ?? '' );

        $methods = $this->repo->get_all( $lang, $status );

        // Compute config_status from settings BEFORE stripping. Settings carry
        // sensitive credentials and are removed from the list response, but a
        // boolean misconfiguration flag is safe to expose so the SPA can render
        // a "Needs Setup" pill on broken gateways (Pattern #69, S135).
        $methods = array_map( function ( $m ) {
            $m['config_status'] = $this->compute_config_status( $m );
            unset( $m['settings'] );
            return $m;
        }, $methods );

        return new WP_REST_Response( $methods );
    }

    /** GET /payment-methods/{id} */
    public function get_method( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id     = (int) $request->get_param( 'id' );
        $lang   = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );
        $method = $this->repo->get_by_id( $id, $lang );

        if ( ! $method ) {
            return new WP_Error( 'tc_not_found', __( 'Payment method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        // Mask password-type fields in the response (show placeholder, not real
        // value). S430: inline block extracted to mask_gateway_secrets() so the
        // create/update echoes share it.
        return new WP_REST_Response( $this->mask_gateway_secrets( $method ) );
    }

    /** POST /payment-methods */
    public function create_method( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $body = $request->get_json_params() ?: [];

        $gateway = sanitize_key( $body['gateway'] ?? '' );
        if ( ! $gateway ) {
            return new WP_Error( 'tc_missing_field', __( 'gateway is required.', 'titancart-lite' ), [ 'status' => 422 ] );
        }

        $registry = GatewayRegistry::instance();
        if ( ! $registry->has( $gateway ) ) {
            return new WP_Error( 'tc_unknown_gateway', __( 'Unknown gateway slug.', 'titancart-lite' ), [ 'status' => 422 ] );
        }


        $id = $this->repo->create(
            $body,
            $body['translations'] ?? []
        );

        if ( ! $id ) {
            return new WP_Error( 'tc_create_failed', __( 'Could not create payment method.', 'titancart-lite' ), [ 'status' => 500 ] );
        }

        LogService::info( 'general', 'create', 'Payment gateway created: ' . $gateway . ' (#' . $id . ')', [
            'object_type' => 'payment_method',
            'object_id'   => $id,
            'gateway'     => $gateway,
        ] );

        // S430 (KI-96 sibling): the 201 echo runs the same masking as the GETs —
        // returning get_by_id() raw echoed the just-saved secret back.
        return new WP_REST_Response( $this->mask_gateway_secrets( $this->repo->get_by_id( $id ) ), 201 );
    }

    /** PUT /payment-methods/{id} */
    public function update_method( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id     = (int) $request->get_param( 'id' );
        $body   = $request->get_json_params() ?: [];
        $method = $this->repo->get_by_id( $id );

        if ( ! $method ) {
            return new WP_Error( 'tc_not_found', __( 'Payment method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }


        // Merge existing settings with incoming settings so partial updates work.
        if ( isset( $body['settings'] ) && is_array( $body['settings'] ) ) {
            $existing_settings = $this->repo->get_settings( $id );

            // Do NOT overwrite masked password placeholders — keep the original.
            // (Sentinel hoisted to the shared constant at S430; drifted copies
            // would write literal bullets over a live credential.)
            foreach ( $body['settings'] as $key => $value ) {
                if ( $value === Secrets::MASK ) {
                    $body['settings'][ $key ] = $existing_settings[ $key ] ?? '';
                }
            }

            $body['settings'] = array_merge( $existing_settings, $body['settings'] );
        }

        $ok = $this->repo->update( $id, $body, $body['translations'] ?? [] );

        if ( ! $ok ) {
            return new WP_Error( 'tc_update_failed', __( 'Update failed.', 'titancart-lite' ), [ 'status' => 500 ] );
        }

        LogService::info( 'general', 'update', 'Payment gateway updated: #' . $id, [
            'object_type' => 'payment_method',
            'object_id'   => $id,
        ] );

        // S430 (KI-96 sibling): the 200 echo runs the same masking as the GETs —
        // returning get_by_id() raw echoed Stripe/PayPal secrets back on every
        // gateway save (found S429, the exact leak closed at Shipping's L615).
        return new WP_REST_Response( $this->mask_gateway_secrets( $this->repo->get_by_id( $id ) ) );
    }

    /** DELETE /payment-methods/{id} — real, cascading delete (KI-97, S424). */
    public function delete_method( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id = (int) $request->get_param( 'id' );

        if ( ! $this->repo->get_by_id( $id ) ) {
            return new WP_Error( 'tc_not_found', __( 'Payment method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        // ⚠️ Counted BEFORE delete(): the backfill inside it sets payment_info on
        // exactly the orders `backfilled` is counting, so calling this afterwards
        // would always report 0 and the log would claim nothing was protected.
        $usage = $this->repo->count_usage( $id );

        if ( ! $this->repo->delete( $id ) ) {
            return new WP_Error( 'tc_not_found', __( 'Payment method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        LogService::info(
            'general',
            'delete',
            sprintf(
                'Payment gateway deleted: #%d (referenced by %d order(s), %d backfilled with a name snapshot, %d transaction(s) retained)',
                $id,
                $usage['orders'],
                $usage['backfilled'],
                $usage['transactions']
            ),
            [
                'object_type' => 'payment_method',
                'object_id'   => $id,
            ]
        );

        return new WP_REST_Response( [ 'deleted' => true, 'id' => $id, 'usage' => $usage ] );
    }

    /** GET /payment-methods/{id}/fields */
    public function get_gateway_fields( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id     = (int) $request->get_param( 'id' );
        $method = $this->repo->get_by_id( $id );

        if ( ! $method ) {
            return new WP_Error( 'tc_not_found', __( 'Payment method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $registry = GatewayRegistry::instance();
        $gateway  = $registry->make( $method['gateway'] ?? '' );

        if ( ! $gateway ) {
            return new WP_Error( 'tc_unknown_gateway', __( 'Gateway not registered.', 'titancart-lite' ), [ 'status' => 422 ] );
        }

        return new WP_REST_Response( [
            'gateway' => $method['gateway'],
            'label'   => $gateway->get_label(),
            'fields'  => $gateway->get_settings_fields(),
        ] );
    }

    /** GET /payment-gateways — list all registered gateway types */
    public function get_available_gateways( WP_REST_Request $request ): WP_REST_Response {
        $registry = GatewayRegistry::instance();
        $result   = [];

        foreach ( $registry->all() as $slug => $class ) {
            $gw = $registry->make( $slug );
            if ( $gw ) {
                $result[] = [
                    'slug'   => $slug,
                    'label'  => $gw->get_label(),
                    'fields' => $gw->get_settings_fields(),
                    // Edition gate (seam #1 feed): true when this gateway is
                    // Pro-only (anything outside LITE_GATEWAYS, fail-closed).
                    // R1 §🅰 (S452): in Lite the advanced gateways are physically
                    // absent from the registry, so this list only ever contains
                    // baseline gateways and 'pro' is constantly false there —
                    // the old badge-and-lock dropdown pattern is DEAD for Lite.
                    // On Pro the flag still drives the marketplace-style badges.
                    'pro'    => $this->gateway_requires_pro( $slug ),
                ];
            }
        }

        return new WP_REST_Response( $result );
    }

    /** GET /payment-gateways/{slug}/fields — get fields for a gateway type (no saved method needed) */
    public function get_gateway_fields_by_slug( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $slug     = sanitize_key( $request->get_param( 'slug' ) );
        $registry = GatewayRegistry::instance();
        $gateway  = $registry->make( $slug );

        if ( ! $gateway ) {
            return new WP_Error( 'tc_unknown_gateway', __( 'Gateway not registered.', 'titancart-lite' ), [ 'status' => 422 ] );
        }

        return new WP_REST_Response( [
            'slug'   => $slug,
            'label'  => $gateway->get_label(),
            'fields' => $gateway->get_settings_fields(),
        ] );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // HANDLERS — TEST CONNECTION
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * POST /payment-methods/{id}/test
     *
     * Tests gateway connectivity by performing a lightweight, non-destructive API call.
     * - Stripe:  GET /v1/balance (validates secret key)
     * - PayPal:  OAuth2 token request (validates client_id + secret)
     * - Offline: Returns instant success (no API to test)
     */
    public function test_connection( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $id     = (int) $request->get_param( 'id' );
        $method = $this->repo->get_by_id( $id );

        if ( ! $method ) {
            return new WP_Error( 'tc_not_found', __( 'Payment method not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $gateway_slug = $method['gateway'] ?? '';
        $settings     = $method['settings'] ?? [];

        // ── Offline gateways — nothing to test ──────────────────────────────
        $offline = [ 'cod', 'bank_transfer', 'check' ];
        if ( in_array( $gateway_slug, $offline, true ) ) {
            return new WP_REST_Response( [
                'success' => true,
                'message' => __( 'This is an offline payment method — no API connection to test. It\'s ready to use.', 'titancart-lite' ),
            ] );
        }

        // ── Stripe — call GET /v1/balance ───────────────────────────────────
        if ( $gateway_slug === 'stripe' ) {
            $secret_key = $settings['secret_key'] ?? '';
            if ( empty( $secret_key ) ) {
                return new WP_REST_Response( [
                    'success' => false,
                    'message' => __( 'Secret Key is empty. Please enter your Stripe secret key and save before testing.', 'titancart-lite' ),
                ] );
            }

            $response = wp_remote_get( 'https://api.stripe.com/v1/balance', [
                'headers' => [
                    'Authorization' => 'Bearer ' . $secret_key,
                ],
                'timeout' => 15,
            ] );

            if ( is_wp_error( $response ) ) {
                return new WP_REST_Response( [
                    'success' => false,
                    'message' => sprintf(
                        /* translators: %s: connection error message. */
                        __( 'Connection failed: %s', 'titancart-lite' ),
                        $response->get_error_message()
                    ),
                ] );
            }

            $code = wp_remote_retrieve_response_code( $response );
            $body = json_decode( wp_remote_retrieve_body( $response ), true );

            if ( $code === 200 && isset( $body['available'] ) ) {
                $is_test = str_starts_with( $secret_key, 'sk_test_' );
                return new WP_REST_Response( [
                    'success' => true,
                    'message' => sprintf(
                        /* translators: %s: sentence describing whether test or live mode keys are in use. */
                        __( 'Stripe connection successful! %s', 'titancart-lite' ),
                        $is_test
                            ? __( 'You are using TEST mode keys.', 'titancart-lite' )
                            : __( 'You are using LIVE mode keys — real charges will be processed.', 'titancart-lite' )
                    ),
                ] );
            }

            $error_msg = $body['error']['message'] ?? __( 'Unknown Stripe error.', 'titancart-lite' );
            return new WP_REST_Response( [
                'success' => false,
                /* translators: %s: error message returned by the Stripe API. */
                'message' => sprintf( __( 'Stripe rejected the key: %s', 'titancart-lite' ), $error_msg ),
            ] );
        }


        // ── Unknown gateway — generic fallback ──────────────────────────────
        return new WP_REST_Response( [
            'success' => false,
            'message' => __( 'No connection test is available for this gateway type.', 'titancart-lite' ),
        ] );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // HANDLERS — CHECKOUT (public)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * GET /checkout/payment-methods
     *
     * Returns active methods with label + instructions but WITHOUT settings.
     * This is the data the checkout JS uses to render the payment selector.
     */
    public function get_checkout_methods( WP_REST_Request $request ): WP_REST_Response {
        $lang    = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );
        $methods = $this->repo->get_active( $lang );

        // Edition gate (seam #2): never offer a Pro gateway to shoppers on a Lite
        // install, even if one is still marked active from a prior Pro period.
        // Closes the "usable at checkout" leak the write gate alone would miss.
        // INERT on Pro.
        if ( ! \TitanCart\Core\Edition::is_pro() ) {
            $methods = array_values( array_filter(
                $methods,
                fn( $m ) => ! $this->gateway_requires_pro( (string) ( $m['gateway'] ?? '' ) )
            ) );
        }

        // Strip sensitive settings from the public response.
        $safe = array_map( function ( $m ) {
            return [
                'id'           => $m['id'],
                'gateway'      => $m['gateway'],
                'label'        => $m['label'],
                'description'  => $m['description'],
                'instructions' => $m['instructions'],
                'surcharge'    => $m['surcharge'],
                'surcharge_type' => $m['surcharge_type'],
                'test_mode'    => (bool) $m['test_mode'],
            ];
        }, $methods );

        return new WP_REST_Response( $safe );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // HANDLERS — REFUNDS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * POST /orders/{id}/refund
     *
     * Body: {
     *   amount: float,
     *   reason: string,
     *   line_items?: [{ order_item_id: int, amount: float }]
     * }
     *
     * line_items (optional, S195) — line-item refund targeting:
     *   When provided, identifies specific order items being refunded.
     *   Each entry's `amount` must be > 0, the `order_item_id` must
     *   belong to this order, and the sum of entry amounts must equal
     *   the top-level `amount` (within 1-cent DECIMAL epsilon).
     *   Validation rejects with HTTP 422 if any check fails.
     *
     *   Forwarded as the 5th arg of `titancart_refund_issued` for
     *   downstream listeners that need line-aware attribution. Notable
     *   consumer: TitanCart Marketplace's TransferReversalListener
     *   (S195 B6 CLOSE) uses this to claw back the correct vendor's
     *   share via Stripe Transfer Reversals — "whoever's item is being
     *   refunded loses the money."
     *
     *   When omitted, the action fires with an empty array. Existing
     *   `{amount, reason}`-only admin UIs continue to work; downstream
     *   listeners can either synthesize line targeting from the empty
     *   payload (full-order refund case) or skip with a warning.
     */
    public function issue_refund( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $order_id = (int) $request->get_param( 'id' );
        $body     = $request->get_json_params() ?: [];
        $amount   = (float) ( $body['amount'] ?? 0 );

        if ( $amount <= 0 ) {
            return new WP_Error( 'tc_invalid_amount', __( 'Refund amount must be greater than zero.', 'titancart-lite' ), [ 'status' => 422 ] );
        }

        // Load the order.
        $db    = titancart_db();
        $order = $db->getRow( "SELECT * FROM {$db->table('orders')} WHERE id = ?i LIMIT 1", $order_id );

        if ( ! $order ) {
            return new WP_Error( 'tc_not_found', __( 'Order not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        // Guard: cannot refund more than the order total.
        if ( $amount > (float) $order['total'] ) {
            return new WP_Error(
                'tc_refund_too_large',
                __( 'Refund amount exceeds order total.', 'titancart-lite' ),
                [ 'status' => 422 ]
            );
        }

        // ── S195: optional line_items validation ─────────────────────────────
        //
        // When admin / API client provides line-item targeting, validate that
        // (a) entries are structurally valid, (b) each order_item_id belongs
        // to this order, and (c) line amounts sum to the top-level amount.
        // Forwarded to the action hook as the 5th arg for downstream
        // attribution (e.g. marketplace transfer reversals).
        //
        // Per-line cumulative refund tracking across multiple refund attempts
        // is NOT enforced here — that's B10 admin tooling work (would require
        // a tc_refund_line_items ledger). For S195, the existing aggregate
        // `amount > order.total` guard above protects against full over-refund;
        // downstream marketplace listener defensively caps per-vendor reversal
        // at remaining_vendor_share to prevent over-claw in the edge case.
        $line_items = [];
        if ( isset( $body['line_items'] ) && is_array( $body['line_items'] ) && ! empty( $body['line_items'] ) ) {
            $sanitized = [];
            $line_sum  = 0.0;

            foreach ( $body['line_items'] as $idx => $li ) {
                if ( ! is_array( $li ) ) {
                    return new WP_Error(
                        'tc_invalid_line_items',
                        /* translators: %d: zero-based index of the line item in the request. */
                        sprintf( __( 'line_items[%d] must be an object.', 'titancart-lite' ), $idx ),
                        [ 'status' => 422 ]
                    );
                }

                $oi_id = isset( $li['order_item_id'] ) ? (int) $li['order_item_id'] : 0;
                $li_amt = isset( $li['amount'] ) ? (float) $li['amount'] : 0.0;

                if ( $oi_id <= 0 ) {
                    return new WP_Error(
                        'tc_invalid_line_items',
                        /* translators: %d: zero-based index of the line item in the request. */
                        sprintf( __( 'line_items[%d].order_item_id must be a positive integer.', 'titancart-lite' ), $idx ),
                        [ 'status' => 422 ]
                    );
                }
                if ( $li_amt <= 0 ) {
                    return new WP_Error(
                        'tc_invalid_line_items',
                        /* translators: %d: zero-based index of the line item in the request. */
                        sprintf( __( 'line_items[%d].amount must be greater than zero.', 'titancart-lite' ), $idx ),
                        [ 'status' => 422 ]
                    );
                }

                $sanitized[] = [
                    'order_item_id' => $oi_id,
                    'amount'        => round( $li_amt, 4 ),
                ];
                $line_sum += $li_amt;
            }

            // Sum check: line totals must equal top-level amount (1-cent epsilon
            // for DECIMAL(N,4) float drift).
            if ( abs( $line_sum - $amount ) > 0.01 ) {
                return new WP_Error(
                    'tc_invalid_line_items',
                    sprintf(
                        /* translators: 1: sum of the line item amounts. 2: refund amount requested. */
                        __( 'Sum of line_items amounts (%1$s) does not match top-level amount (%2$s).', 'titancart-lite' ),
                        number_format( $line_sum, 2 ),
                        number_format( $amount, 2 )
                    ),
                    [ 'status' => 422 ]
                );
            }

            // Verify each order_item_id belongs to this order.
            $ids = array_map(
                static fn( $li ) => (int) $li['order_item_id'],
                $sanitized
            );
            $placeholders = implode( ',', array_fill( 0, count( $ids ), '?i' ) );

            $valid_count = (int) $db->getField(
                "SELECT COUNT(*) FROM {$db->table('order_items')}
                  WHERE order_id = ?i AND id IN ({$placeholders})",
                $order_id,
                ...$ids
            );

            if ( $valid_count !== count( $ids ) ) {
                return new WP_Error(
                    'tc_invalid_line_items',
                    __( 'One or more line_items.order_item_id values do not belong to this order.', 'titancart-lite' ),
                    [ 'status' => 422 ]
                );
            }

            $line_items = $sanitized;
        }

        // Refund-eligibility gate (S432). Fired before ANY gateway work so an
        // ineligible order never reaches the processor. Default = eligible; on
        // stores with no listeners this is a no-op and behavior is unchanged.
        // Known listener: PluginPress' PP_Refund_Guard denies refunds for
        // orders whose licensed software has already been downloaded (store
        // policy: downloaded ⇒ not refundable; exchange/store credit remain
        // available via RMA, which does not gate those actions).
        $eligibility = apply_filters(
            'titancart_refund_eligibility',
            [ 'eligible' => true, 'reason' => '' ],
            $order_id,
            $amount,
            'admin'
        );

        if ( ! is_array( $eligibility ) || empty( $eligibility['eligible'] ) ) {
            $reason = ( is_array( $eligibility ) && ! empty( $eligibility['reason'] ) )
                ? (string) $eligibility['reason']
                : __( 'This order is not eligible for a refund.', 'titancart-lite' );

            return new WP_Error( 'tc_refund_ineligible', $reason, [ 'status' => 422 ] );
        }

        // Find the completed charge transaction.
        $txn = $this->txn_repo->get_latest_charge( $order_id );

        if ( ! $txn ) {
            return new WP_Error(
                'tc_no_charge',
                __( 'No completed payment found for this order.', 'titancart-lite' ),
                [ 'status' => 422 ]
            );
        }

        // Resolve the gateway.
        $registry = GatewayRegistry::instance();
        $gateway  = $registry->make( $txn['gateway'] );

        if ( ! $gateway ) {
            return new WP_Error(
                'tc_unknown_gateway',
                __( 'Gateway not registered — cannot issue refund.', 'titancart-lite' ),
                [ 'status' => 422 ]
            );
        }

        $method   = ( new PaymentMethodRepository() )->get_by_id( (int) $txn['payment_method_id'] );
        $settings = $method['settings'] ?? [];

        $result = $gateway->refund( $order_id, $txn['transaction_id'], $amount, $settings );

        if ( ! $result['success'] ) {
            return new WP_Error( 'tc_refund_failed', $result['message'], [ 'status' => 422 ] );
        }

        // Add an internal order note for the refund.
        $reason = sanitize_text_field( $body['reason'] ?? '' );
        $note   = sprintf(
            /* translators: 1: amount, 2: currency, 3: refund ID, 4: reason */
            __( 'Refund of %1$s %2$s issued. Refund ID: %3$s. Reason: %4$s', 'titancart-lite' ),
            number_format( $amount, 2 ),
            strtoupper( $order['currency'] ?? 'USD' ),
            $result['refund_id'],
            $reason ?: __( 'No reason given.', 'titancart-lite' )
        );

        $db->insert( 'order_notes', [
            'order_id'  => $order_id,
            'note'      => $note,
            'type'      => 'internal',
            'author_id' => get_current_user_id(),
        ] );

        /**
         * Fires after a refund has been issued.
         *
         * Extended at S195 to carry an optional `line_items` 5th arg for
         * cross-extension line-aware attribution (notably marketplace
         * transfer reversals — "whoever's item is being refunded loses
         * the money"). Existing 4-arg listeners (e.g. TC core's
         * `PaymentsModule::handle_refund_issued`) remain unaffected
         * because `add_action` silently truncates extra args beyond the
         * registered `$accepted_args` count.
         *
         * @param int    $order_id
         * @param float  $amount
         * @param string $refund_id
         * @param array  $result
         * @param array  $line_items  [S195] Optional line-item targeting.
         *                            Empty array when caller didn't provide.
         */
        do_action( 'titancart_refund_issued', $order_id, $amount, $result['refund_id'], $result, $line_items );

        return new WP_REST_Response( $result );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // HANDLERS — TRANSACTIONS
    // ══════════════════════════════════════════════════════════════════════════

    /** GET /orders/{id}/transactions */
    public function get_transactions( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $order_id = (int) $request->get_param( 'id' );
        $db       = titancart_db();

        $order = $db->getRow( "SELECT id FROM {$db->table('orders')} WHERE id = ?i LIMIT 1", $order_id );
        if ( ! $order ) {
            return new WP_Error( 'tc_not_found', __( 'Order not found.', 'titancart-lite' ), [ 'status' => 404 ] );
        }

        $transactions = $this->txn_repo->get_for_order( $order_id );

        // Decode gateway_response JSON for easier consumption by the React UI.
        $transactions = array_map( function ( $t ) {
            if ( isset( $t['gateway_response'] ) && is_string( $t['gateway_response'] ) ) {
                $t['gateway_response'] = json_decode( $t['gateway_response'], true ) ?: [];
            }
            return $t;
        }, $transactions );

        return new WP_REST_Response( $transactions );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // CONFIG-STATUS COMPUTATION (Pattern #69, S135)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Compute a config-status flag for a gateway row based on its settings.
     *
     * Returns one of:
     *   'ok'          — gateway is either disabled or fully configured
     *   'needs_setup' — gateway is active but a fail-closed required field
     *                   is empty (payments would silently break)
     *
     * The SPA reads this flag to decorate the gateway list with a warning
     * pill. Computed server-side because the list endpoint strips settings
     * before responding (sensitive credentials never leave the server).
     *
     * Currently audits Stripe's webhook_secret (Pattern #64, S133 fail-closed).
     * Add new gateway checks here as additional fail-closed surfaces ship.
     *
     * Mirrors PaymentsModule::stripe_webhook_secret_missing — must stay in
     * sync with the gating logic in StripeGateway::handle_webhook.
     *
     * @param  array $method  Decoded payment_method row with 'settings' as array.
     * @return string         'ok' | 'needs_setup'
     */
    private function compute_config_status( array $method ): string {
        // Disabled gateways can't break anything — skip.
        if ( ( $method['status'] ?? '' ) !== 'active' ) {
            return 'ok';
        }

        $gateway  = (string) ( $method['gateway'] ?? '' );
        $settings = $method['settings'] ?? [];
        if ( ! is_array( $settings ) ) {
            $settings = [];
        }

        // ── Stripe: webhook_secret required (Pattern #64 fail-closed, S133) ──
        if ( $gateway === 'stripe' ) {
            // Dev override negates the fail-closed behavior.
            if ( defined( 'TITANCART_ALLOW_UNSIGNED_WEBHOOKS' ) && TITANCART_ALLOW_UNSIGNED_WEBHOOKS ) {
                return 'ok';
            }
            if ( empty( $settings['webhook_secret'] ) ) {
                return 'needs_setup';
            }
        }

        return 'ok';
    }

    // ══════════════════════════════════════════════════════════════════════════
    // EDITION GATE (seam #2 — gateways_advanced)
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Whether a gateway slug requires the Pro edition.
     *
     * A gateway is Pro-only when it is NOT one of the Lite baseline gateways
     * (Stripe + offline). Drives the create/update write gate ("which gateway
     * ids can be activated") and the public checkout-list filter so a Lite store
     * never offers — or can be charged through — a Pro gateway. Fail-closed: an
     * empty/unrecognised slug is NOT treated as Pro here (empty is handled by the
     * caller's own required-field check), but any registered gateway outside the
     * Lite allowlist is.
     *
     * @param  string $slug
     * @return bool
     */
    private function gateway_requires_pro( string $slug ): bool {
        return $slug !== '' && ! in_array( $slug, self::LITE_GATEWAYS, true );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PERMISSIONS
    // ══════════════════════════════════════════════════════════════════════════

    public function admin_permission(): bool {
        return current_user_can( 'manage_options' );
    }
}
