<?php

namespace Sikshya\Api;

use Sikshya\Commerce\OrderFulfillmentService;
use Sikshya\Commerce\CheckoutService;
use Sikshya\Core\Plugin;
use Sikshya\Database\Repositories\OrderRepository;
use Sikshya\Database\Repositories\PaymentRepository;
use Sikshya\Services\CourseService;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

// Prevent direct access.
if (!defined('ABSPATH')) {
    exit;
}

/**
 * Payment provider webhooks (signature verification where configured).
 *
 * @package Sikshya\Api
 */

class WebhooksRestRoutes
{
    private Plugin $plugin;

    public function __construct(Plugin $plugin)
    {
        $this->plugin = $plugin;
    }

    public function register(): void
    {
        $namespace = 'sikshya/v1';

        register_rest_route($namespace, '/webhooks/stripe', [
            [
                'methods' => WP_REST_Server::CREATABLE,
                'callback' => [$this, 'stripe'],
                'permission_callback' => [self::class, 'hasStripeSignature'],
            ],
        ]);

        register_rest_route($namespace, '/webhooks/paypal', [
            [
                'methods' => WP_REST_Server::CREATABLE,
                'callback' => [$this, 'paypal'],
                'permission_callback' => [self::class, 'hasPaypalSignature'],
            ],
        ]);

        // PayPal Standard IPN (Simple mode: email-only).
        register_rest_route($namespace, '/webhooks/paypal-ipn', [
            [
                'methods' => WP_REST_Server::CREATABLE,
                'callback' => [$this, 'paypalIpn'],
                'permission_callback' => [self::class, 'hasIpnPayload'],
            ],
        ]);

        // Mollie webhook (refunds, late captures, chargebacks).
        register_rest_route($namespace, '/webhooks/mollie', [
            [
                'methods' => WP_REST_Server::CREATABLE,
                'callback' => [$this, 'mollie'],
                // Mollie webhooks have NO HTTP-level signature — auth is
                // by us calling back into Mollie's API (TLS + bearer key)
                // to verify state. We require a payment id in the body
                // to weed out crawlers / probes; the actual auth happens
                // inside the handler via `getMolliePayment()`.
                'permission_callback' => [self::class, 'hasMolliePayload'],
            ],
        ]);

        // Paystack webhook (HMAC-SHA512 signature in `x-paystack-signature`).
        register_rest_route($namespace, '/webhooks/paystack', [
            [
                'methods' => WP_REST_Server::CREATABLE,
                'callback' => [$this, 'paystack'],
                'permission_callback' => [self::class, 'hasPaystackSignature'],
            ],
        ]);

        // Razorpay webhook (HMAC-SHA256 signature in `x-razorpay-signature`).
        register_rest_route($namespace, '/webhooks/razorpay', [
            [
                'methods' => WP_REST_Server::CREATABLE,
                'callback' => [$this, 'razorpay'],
                'permission_callback' => [self::class, 'hasRazorpaySignature'],
            ],
        ]);
    }

    /**
     * Mollie webhooks deliver `id=tr_xxx` as POST body. Bare empty calls or
     * GETs are turned away at the routing layer.
     */
    public static function hasMolliePayload(WP_REST_Request $request): bool
    {
        $params = $request->get_body_params();
        return is_array($params) && isset($params['id']) && is_string($params['id']) && $params['id'] !== '';
    }

    public static function hasPaystackSignature(): bool
    {
        return isset($_SERVER['HTTP_X_PAYSTACK_SIGNATURE']) && (string) $_SERVER['HTTP_X_PAYSTACK_SIGNATURE'] !== '';
    }

    public static function hasRazorpaySignature(): bool
    {
        return isset($_SERVER['HTTP_X_RAZORPAY_SIGNATURE']) && (string) $_SERVER['HTTP_X_RAZORPAY_SIGNATURE'] !== '';
    }

    /**
     * Permission precheck for the Stripe webhook route.
     *
     * Stripe always sends `Stripe-Signature`. A request without it is either misconfigured
     * or a probe; turn it away at the routing layer so the route callback doesn't even run.
     * Full HMAC verification (constant-time, with timestamp tolerance) still happens in
     * {@see self::verifyStripeSignature()} inside the callback.
     */
    public static function hasStripeSignature(): bool
    {
        return isset($_SERVER['HTTP_STRIPE_SIGNATURE']) && (string) $_SERVER['HTTP_STRIPE_SIGNATURE'] !== '';
    }

    /**
     * Permission precheck for the PayPal webhook route — requires the full set of PayPal
     * signature headers. Full cryptographic verify still runs in
     * {@see self::verifyPayPalSignature()} inside the callback.
     */
    public static function hasPaypalSignature(): bool
    {
        foreach (['HTTP_PAYPAL_TRANSMISSION_ID', 'HTTP_PAYPAL_TRANSMISSION_TIME', 'HTTP_PAYPAL_TRANSMISSION_SIG', 'HTTP_PAYPAL_CERT_URL', 'HTTP_PAYPAL_AUTH_ALGO'] as $header) {
            if (!isset($_SERVER[$header]) || (string) $_SERVER[$header] === '') {
                return false;
            }
        }

        return true;
    }

    /**
     * Permission precheck for the PayPal IPN route — IPN posts form-encoded params with no
     * signature header, so we just require POST body. The cmd=_notify-validate round-trip
     * to PayPal (already fail-closed) does the real verification.
     */
    public static function hasIpnPayload(WP_REST_Request $request): bool
    {
        $params = $request->get_body_params();

        return is_array($params) && $params !== [];
    }

    public function stripe(WP_REST_Request $request): WP_REST_Response
    {
        $settings = $this->plugin->getService('settings');
        $secret = '';
        if (is_object($settings) && method_exists($settings, 'getSetting')) {
            $secret = (string) $settings->getSetting('stripe_webhook_secret', '');
        }

        $payload = $request->get_body();
        $sig = isset($_SERVER['HTTP_STRIPE_SIGNATURE']) ? (string) $_SERVER['HTTP_STRIPE_SIGNATURE'] : '';

        // Security: if the webhook endpoint is hit, a signing secret must be configured
        // and the signature must verify. Otherwise this endpoint becomes spoofable.
        if ($secret === '') {
            return new WP_REST_Response(
                ['ok' => false, 'message' => 'Stripe webhook secret is not configured.'],
                400
            );
        }
        if (!$this->verifyStripeSignature($payload, $sig, $secret)) {
            return new WP_REST_Response(['ok' => false, 'message' => 'Invalid signature'], 400);
        }

        $event = json_decode($payload, true);
        if (!is_array($event)) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        $type = (string) ($event['type'] ?? '');
        $obj = $event['data']['object'] ?? [];
        if (!is_array($obj)) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        // One-time payments (Payment Intent).
        if ($type === 'payment_intent.succeeded') {
            $pi = $obj;
            $order_id = 0;
            if (!empty($pi['metadata']['order_id'])) {
                $order_id = (int) $pi['metadata']['order_id'];
            }

            if ($order_id <= 0 && !empty($pi['id'])) {
                $orders = new OrderRepository();
                $row = $orders->findByGatewayIntent('stripe', (string) $pi['id']);
                if ($row) {
                    $order_id = (int) $row->id;
                }
            }

            if ($order_id > 0) {
                $orders = new OrderRepository();
                $row = $orders->findById($order_id);
                if ($row) {
                    $pi_amount = is_array($pi) && isset($pi['amount_received']) ? (int) $pi['amount_received'] : 0;
                    if ($pi_amount <= 0 && is_array($pi) && isset($pi['amount'])) {
                        $pi_amount = (int) $pi['amount'];
                    }
                    $pi_cur = is_array($pi) && isset($pi['currency']) ? strtoupper((string) $pi['currency']) : '';
                    $expected_minor = CheckoutService::toMinorUnits((float) $row->total, (string) $row->currency);
                    if ($pi_cur !== '' && $pi_cur !== strtoupper((string) $row->currency)) {
                        return new WP_REST_Response(['ok' => false, 'message' => 'Currency mismatch'], 400);
                    }
                    if ($pi_amount > 0 && $expected_minor > 0 && $pi_amount !== $expected_minor) {
                        return new WP_REST_Response(['ok' => false, 'message' => 'Amount mismatch'], 400);
                    }
                    if (!$this->fulfillment()->fulfillPaidOrder($order_id)) {
                        error_log(sprintf('Sikshya: fulfillPaidOrder failed for order %d (Stripe payment_intent.succeeded).', $order_id));

                        return new WP_REST_Response(['ok' => false, 'message' => 'Fulfillment failed'], 500);
                    }
                }
            }
            return new WP_REST_Response(['ok' => true], 200);
        }

        // Charge fully refunded — reverse the local fulfilment. Stripe also
        // delivers `payment_intent.canceled` for some flows; we treat both
        // the same (find the order, run the refund service).
        if (in_array($type, ['charge.refunded', 'payment_intent.canceled'], true)) {
            // Only act on FULLY refunded charges in v1. Partial refunds are
            // surfaced via the action so listeners can decide, but we don't
            // unenrol the learner from a $100 course because they got $5
            // refunded.
            $is_full_refund = true;
            $refunded_amount = null;
            if ($type === 'charge.refunded') {
                $charge_amount = isset($obj['amount']) ? (int) $obj['amount'] : 0;
                $charge_refunded = isset($obj['amount_refunded']) ? (int) $obj['amount_refunded'] : 0;
                $refunded_flag = !empty($obj['refunded']);
                $is_full_refund = $refunded_flag || ($charge_amount > 0 && $charge_refunded >= $charge_amount);
                $refunded_amount = $charge_refunded > 0 ? $charge_refunded / 100.0 : null;
            }

            // Stripe doesn't put metadata on the charge directly — we resolve
            // the order via the linked payment_intent ID, which we stored
            // when the original fulfilment ran.
            $intent_id = '';
            if ($type === 'charge.refunded') {
                $intent_id = (string) ($obj['payment_intent'] ?? '');
            } else { // payment_intent.canceled
                $intent_id = (string) ($obj['id'] ?? '');
            }

            if ($intent_id !== '') {
                $order_row = (new OrderRepository())->findByGatewayIntent('stripe', $intent_id);
                if ($order_row && $is_full_refund) {
                    if (!$this->refunds()->refundFullOrder(
                        (int) $order_row->id,
                        $type === 'charge.refunded' ? 'stripe_charge_refunded' : 'stripe_payment_intent_canceled',
                        $refunded_amount
                    )) {
                        error_log(sprintf('Sikshya: refundFullOrder failed for order %d (Stripe %s).', (int) $order_row->id, $type));
                        return new WP_REST_Response(['ok' => false, 'message' => 'Refund failed'], 500);
                    }
                } elseif ($order_row && !$is_full_refund) {
                    // Partial refund: don't unenrol but still surface for
                    // listeners (Pro audit log, instructor notifications)
                    // so the refund is at least recorded somewhere visible.
                    do_action(
                        'sikshya_order_partial_refund',
                        (int) $order_row->id,
                        $refunded_amount,
                        'stripe_charge_refunded',
                        $obj
                    );
                }
            }
            return new WP_REST_Response(['ok' => true], 200);
        }

        // Let Pro/addons handle subscription events without embedding Pro logic in core.
        if (in_array($type, ['checkout.session.completed', 'customer.subscription.updated', 'customer.subscription.deleted'], true)) {
            do_action('sikshya_webhook_stripe_subscription_event', $event, $obj, $type, $this);
            return new WP_REST_Response(['ok' => true], 200);
        }

        // Subscription renewal charges (Stripe Billing invoices). Core dispatches only; Pro records `sikshya_payments` rows.
        if (in_array($type, ['invoice.paid', 'invoice.payment_succeeded'], true)) {
            do_action('sikshya_webhook_stripe_invoice_payment', $event, $obj, $type, $this);
            return new WP_REST_Response(['ok' => true], 200);
        }

        return new WP_REST_Response(['ok' => true], 200);
    }

    public function paypal(WP_REST_Request $request): WP_REST_Response
    {
        // Verify webhook signature when configured.
        $settings = $this->plugin->getService('settings');
        $paypal_client_id = '';
        $paypal_secret = '';
        $paypal_webhook_id = '';
        if (is_object($settings) && method_exists($settings, 'getSetting')) {
            $paypal_client_id = (string) $settings->getSetting('paypal_client_id', '');
            $paypal_secret = (string) $settings->getSetting('paypal_secret', '');
            $paypal_webhook_id = (string) $settings->getSetting('paypal_webhook_id', '');
        }

        $payload = $request->get_body();
        // Security: require signature verification for PayPal webhooks.
        // If site owner hasn't configured webhook id/credentials, reject to avoid spoofed fulfillment.
        if ($payload === '') {
            return new WP_REST_Response(['ok' => false, 'message' => 'Empty payload'], 400);
        }
        if ($paypal_webhook_id === '' || $paypal_client_id === '' || $paypal_secret === '') {
            return new WP_REST_Response(
                ['ok' => false, 'message' => 'PayPal webhook is not configured.'],
                400
            );
        }
        $ok = $this->verifyPayPalSignature(
            $payload,
            $paypal_client_id,
            $paypal_secret,
            '', // mode is derived from global enable_test_mode inside verifyPayPalSignature
            $paypal_webhook_id
        );
        if (!$ok) {
            return new WP_REST_Response(['ok' => false, 'message' => 'Invalid signature'], 400);
        }

        $json = json_decode($payload, true);
        if (!is_array($json)) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        $type = (string) ($json['event_type'] ?? '');

        if (in_array($type, ['BILLING.SUBSCRIPTION.ACTIVATED', 'BILLING.SUBSCRIPTION.UPDATED', 'BILLING.SUBSCRIPTION.CANCELLED', 'BILLING.SUBSCRIPTION.SUSPENDED'], true)) {
            do_action('sikshya_webhook_paypal_subscription_event', $json, $type, $this);
            return new WP_REST_Response(['ok' => true], 200);
        }

        // Capture-level refund event. PayPal propagates the originating
        // purchase_unit's `custom_id` (which we set to local order_id at
        // checkout — see CheckoutService line 823) onto refund resources, so
        // we can look up the local order without an extra API call.
        if ($type === 'PAYMENT.CAPTURE.REFUNDED') {
            $resource = $json['resource'] ?? [];
            if (!is_array($resource)) {
                return new WP_REST_Response(['ok' => true], 200);
            }
            $custom_id = (string) ($resource['custom_id'] ?? '');
            $order_id = $custom_id !== '' ? (int) $custom_id : 0;
            if ($order_id <= 0) {
                // Older captures may not carry custom_id — surface for
                // listeners but don't refund (we can't safely identify the
                // local order from a refund event without it).
                return new WP_REST_Response(['ok' => true], 200);
            }

            // Full vs partial: compare refund amount to original order total.
            $refund_amount = isset($resource['amount']['value']) ? (float) $resource['amount']['value'] : 0.0;
            $order_row = (new OrderRepository())->findById($order_id);
            if (!$order_row) {
                return new WP_REST_Response(['ok' => true], 200);
            }
            $is_full = $refund_amount > 0 && abs($refund_amount - (float) $order_row->total) < 0.01;

            if ($is_full) {
                if (!$this->refunds()->refundFullOrder($order_id, 'paypal_capture_refunded', $refund_amount)) {
                    error_log(sprintf('Sikshya: refundFullOrder failed for order %d (PayPal PAYMENT.CAPTURE.REFUNDED).', $order_id));
                    return new WP_REST_Response(['ok' => false, 'message' => 'Refund failed'], 500);
                }
            } else {
                do_action('sikshya_order_partial_refund', $order_id, $refund_amount, 'paypal_capture_refunded', $resource);
            }
            return new WP_REST_Response(['ok' => true], 200);
        }

        if ($type !== 'PAYMENT.CAPTURE.COMPLETED') {
            return new WP_REST_Response(['ok' => true, 'ignored' => true], 200);
        }

        $resource = $json['resource'] ?? [];
        if (!is_array($resource)) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        $suppl = $resource['supplementary_data'] ?? [];
        $related = is_array($suppl) ? ($suppl['related_ids']['order_id'] ?? '') : '';
        $paypal_order_id = is_string($related) ? $related : '';

        if ($paypal_order_id === '') {
            // Some payloads include custom_id / invoice_id — fall back to amount only in production.
            return new WP_REST_Response(['ok' => true], 200);
        }

        $orders = new OrderRepository();
        $row = $orders->findByGatewayIntent('paypal', $paypal_order_id);
        if ($row) {
            $pu0 = isset($resource['purchase_units'][0]) && is_array($resource['purchase_units'][0]) ? $resource['purchase_units'][0] : null;
            $amt = is_array($pu0) && isset($pu0['amount']['value']) ? (float) $pu0['amount']['value'] : null;
            $cur = is_array($pu0) && isset($pu0['amount']['currency_code']) ? (string) $pu0['amount']['currency_code'] : '';
            if (is_string($cur) && $cur !== '' && strtoupper($cur) !== strtoupper((string) $row->currency)) {
                return new WP_REST_Response(['ok' => false, 'message' => 'Currency mismatch'], 400);
            }
            if (is_float($amt) && $amt >= 0 && abs($amt - (float) $row->total) > 0.009) {
                return new WP_REST_Response(['ok' => false, 'message' => 'Amount mismatch'], 400);
            }
            $oid = (int) $row->id;
            if (!$this->fulfillment()->fulfillPaidOrder($oid)) {
                error_log(sprintf('Sikshya: fulfillPaidOrder failed for order %d (PayPal webhook).', $oid));

                return new WP_REST_Response(['ok' => false, 'message' => 'Fulfillment failed'], 500);
            }
        }

        return new WP_REST_Response(['ok' => true], 200);
    }

    /**
     * PayPal IPN handler for Simple (email) mode.
     *
     * @return WP_REST_Response
     */
    public function paypalIpn(WP_REST_Request $request): WP_REST_Response
    {
        $settings = $this->plugin->getService('settings');
        $is_test = true;
        if (is_object($settings) && method_exists($settings, 'getSetting')) {
            $test = $settings->getSetting('enable_test_mode', true);
            $is_test = $test === true || $test === 1 || $test === '1' || $test === 'true' || $test === 'yes' || $test === 'on';
        }

        $postData = $request->get_body_params();
        if (!is_array($postData) || $postData === []) {
            return new WP_REST_Response(['ok' => false, 'message' => 'Empty IPN payload'], 400);
        }

        // Verify with PayPal.
        $verifyUrl = $is_test
            ? 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'
            : 'https://ipnpb.paypal.com/cgi-bin/webscr';
        $verifyData = array_merge(['cmd' => '_notify-validate'], $postData);

        $resp = wp_remote_post($verifyUrl, [
            'timeout' => 60,
            'httpversion' => '1.1',
            'body' => $verifyData,
        ]);
        if (is_wp_error($resp)) {
            return new WP_REST_Response(['ok' => false, 'message' => 'IPN verify error'], 400);
        }
        $body = (string) wp_remote_retrieve_body($resp);
        if (trim($body) !== 'VERIFIED') {
            return new WP_REST_Response(['ok' => false, 'message' => 'IPN not verified'], 400);
        }

        $paymentStatus = (string) ($postData['payment_status'] ?? '');

        // Refund / reversal path. PayPal IPN signals these via
        // `payment_status = "Refunded"` (merchant-initiated refund) or
        // `"Reversed"` (buyer dispute / chargeback). The `custom` field
        // carries our serialised order metadata from the original capture
        // — same payload we read on the Completed path below.
        if (in_array($paymentStatus, ['Refunded', 'Reversed'], true)) {
            $custom_raw = (string) ($postData['custom'] ?? '');
            $custom = $custom_raw !== '' ? json_decode($custom_raw, true) : null;
            $order_id_for_refund = is_array($custom) && isset($custom['order_id']) ? (int) $custom['order_id'] : 0;
            if ($order_id_for_refund <= 0) {
                $order_id_for_refund = isset($postData['item_number']) ? (int) $postData['item_number'] : 0;
            }
            if ($order_id_for_refund <= 0) {
                return new WP_REST_Response(['ok' => true], 200);
            }

            // `mc_gross` arrives negative for refunds. Compare its absolute
            // value to the order total to distinguish full vs partial.
            $gross_raw = isset($postData['mc_gross']) ? (float) $postData['mc_gross'] : 0.0;
            $refund_amount = abs($gross_raw);
            $order_row = (new OrderRepository())->findById($order_id_for_refund);
            if (!$order_row) {
                return new WP_REST_Response(['ok' => true], 200);
            }
            $is_full = $refund_amount > 0 && abs($refund_amount - (float) $order_row->total) < 0.01;
            $reason = $paymentStatus === 'Reversed' ? 'paypal_ipn_reversed' : 'paypal_ipn_refunded';

            if ($is_full) {
                if (!$this->refunds()->refundFullOrder($order_id_for_refund, $reason, $refund_amount)) {
                    error_log(sprintf('Sikshya: refundFullOrder failed for order %d (PayPal IPN %s).', $order_id_for_refund, $paymentStatus));
                    return new WP_REST_Response(['ok' => false, 'message' => 'Refund failed'], 500);
                }
            } else {
                do_action('sikshya_order_partial_refund', $order_id_for_refund, $refund_amount, $reason, $postData);
            }
            return new WP_REST_Response(['ok' => true], 200);
        }

        if ($paymentStatus !== 'Completed') {
            return new WP_REST_Response(['ok' => true, 'ignored' => true], 200);
        }

        $customRaw = (string) ($postData['custom'] ?? '');
        $custom = $customRaw !== '' ? json_decode($customRaw, true) : null;
        $order_id = is_array($custom) && isset($custom['order_id']) ? (int) $custom['order_id'] : 0;
        if ($order_id <= 0) {
            // Fallback to item_number if used.
            $order_id = isset($postData['item_number']) ? (int) $postData['item_number'] : 0;
        }
        if ($order_id <= 0) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        $orders = new OrderRepository();
        $row = $orders->findById($order_id);
        if (!$row || (string) ($row->gateway ?? '') !== 'paypal') {
            return new WP_REST_Response(['ok' => true], 200);
        }

        $cur = strtoupper((string) ($postData['mc_currency'] ?? ''));
        $amt = isset($postData['mc_gross']) ? (float) $postData['mc_gross'] : null;
        if ($cur !== '' && strtoupper((string) $row->currency) !== $cur) {
            return new WP_REST_Response(['ok' => false, 'message' => 'Currency mismatch'], 400);
        }
        if (is_float($amt) && $amt >= 0 && abs($amt - (float) $row->total) > 0.009) {
            return new WP_REST_Response(['ok' => false, 'message' => 'Amount mismatch'], 400);
        }

        $oid = (int) $row->id;
        if (!$this->fulfillment()->fulfillPaidOrder($oid)) {
            error_log(sprintf('Sikshya: fulfillPaidOrder failed for order %d (PayPal IPN).', $oid));

            return new WP_REST_Response(['ok' => false, 'message' => 'Fulfillment failed'], 500);
        }

        return new WP_REST_Response(['ok' => true], 200);
    }

    /**
     * Mollie webhook handler. Verifies payment state by calling Mollie's API
     * (server-to-server, secret-key auth) — Mollie webhooks don't carry an
     * HTTP-level signature so this verify-on-receipt pattern IS the auth.
     *
     * Mollie fires this URL on every payment status change: `paid`,
     * `failed`, `expired`, `canceled`, `refunded`, plus subtle ones like
     * `chargeback`. We branch on the observed status + `amountRefunded`
     * field:
     *
     *   - `status === 'paid'` + `amountRefunded === 0`  → ignore (the
     *     `/checkout/confirm` endpoint already fulfilled when the buyer
     *     returned to the site; this is a no-op acknowledgement).
     *   - `amountRefunded > 0`                          → refund branch.
     *     - Full refund (amountRefunded ≈ original amount): call
     *       `refundFullOrder()`.
     *     - Partial: fire `sikshya_order_partial_refund` without unenrolling.
     *   - other statuses                                → silent ack.
     */
    public function mollie(WP_REST_Request $request): WP_REST_Response
    {
        $params = $request->get_body_params();
        $payment_id = is_array($params) && isset($params['id']) ? (string) $params['id'] : '';
        if ($payment_id === '') {
            return new WP_REST_Response(['ok' => true], 200);
        }

        $payment = $this->checkout()->getMolliePayment($payment_id);
        if (!is_array($payment)) {
            // Mollie API unreachable or returned nothing — fail soft (200)
            // so Mollie doesn't retry forever; the next state change will
            // re-deliver. A 5xx here would just queue more retries.
            return new WP_REST_Response(['ok' => true], 200);
        }

        // Resolve local order via metadata.order_id (set at checkout in
        // CheckoutService::createMolliePayment).
        $order_id = 0;
        if (isset($payment['metadata']['order_id'])) {
            $order_id = (int) $payment['metadata']['order_id'];
        }
        if ($order_id <= 0) {
            // Unknown payment — could be a Mollie payment created outside
            // Sikshya sharing the same account. Silent ack.
            return new WP_REST_Response(['ok' => true], 200);
        }

        $orders = new OrderRepository();
        $order_row = $orders->findById($order_id);
        if (!$order_row) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        // Currency + amount integrity check matches what the confirm
        // endpoint already does — protects against an attacker spoofing the
        // webhook by passing a foreign payment id.
        $pay_currency = isset($payment['amount']['currency']) ? strtoupper((string) $payment['amount']['currency']) : '';
        if ($pay_currency !== '' && $pay_currency !== strtoupper((string) $order_row->currency)) {
            return new WP_REST_Response(['ok' => false, 'message' => 'Currency mismatch'], 400);
        }

        $refunded_value = isset($payment['amountRefunded']['value']) ? (float) $payment['amountRefunded']['value'] : 0.0;
        $status = (string) ($payment['status'] ?? '');

        // Refund branch: amountRefunded > 0 OR status moved to refunded.
        if ($refunded_value > 0.0 || $status === 'refunded') {
            $is_full = $refunded_value > 0 && abs($refunded_value - (float) $order_row->total) < 0.01;
            $reason = 'mollie_payment_refunded';
            if ($is_full) {
                if (!$this->refunds()->refundFullOrder($order_id, $reason, $refunded_value)) {
                    error_log(sprintf('Sikshya: refundFullOrder failed for order %d (Mollie webhook).', $order_id));
                    return new WP_REST_Response(['ok' => false, 'message' => 'Refund failed'], 500);
                }
            } else {
                do_action('sikshya_order_partial_refund', $order_id, $refunded_value, $reason, $payment);
            }
            return new WP_REST_Response(['ok' => true], 200);
        }

        // Successful payments are handled by `/checkout/confirm` when the
        // buyer returns — webhook arriving with status=paid is a redundant
        // acknowledgement. Other statuses (failed, expired, canceled) are
        // informational only here.
        return new WP_REST_Response(['ok' => true], 200);
    }

    /**
     * Paystack webhook handler.
     *
     * Auth: HMAC-SHA512 of the raw request body using the merchant's secret
     * key (NOT a separate webhook secret — Paystack's signing key is the
     * same secret key used for API auth). Verified via `hash_equals` so the
     * comparison is constant-time. Without this we'd be accepting forged
     * refund events from anyone able to POST to the public webhook URL.
     *
     * Event dispatch matrix (Paystack documents `event` strings at
     * https://paystack.com/docs/payments/webhooks/):
     *   - `charge.success` — fulfilment is handled by the buyer-return
     *     `/checkout/confirm` flow already; webhook is a redundant ack.
     *   - `refund.processed` / `refund.pending` / `charge.dispute.create`
     *     — refund branch. Compare refund amount (minor units) against
     *     order total. Full → `refundFullOrder`. Partial → action only.
     */
    public function paystack(WP_REST_Request $request): WP_REST_Response
    {
        $settings = $this->plugin->getService('settings');
        $secret = '';
        if (is_object($settings) && method_exists($settings, 'getSetting')) {
            // Paystack supports a dedicated webhook secret too; prefer it
            // when set, otherwise fall back to the API secret key (Paystack
            // signs with the API secret by default).
            $secret = (string) $settings->getSetting('paystack_webhook_secret', '');
            if ($secret === '') {
                $secret = (string) $settings->getSetting('paystack_secret_key', '');
            }
        }
        if ($secret === '') {
            return new WP_REST_Response(['ok' => false, 'message' => 'Paystack secret not configured.'], 400);
        }

        $payload = $request->get_body();
        $sig_header = (string) ($_SERVER['HTTP_X_PAYSTACK_SIGNATURE'] ?? '');
        $expected = hash_hmac('sha512', $payload, $secret);
        if (!hash_equals($expected, $sig_header)) {
            return new WP_REST_Response(['ok' => false, 'message' => 'Invalid signature'], 400);
        }

        $event = json_decode($payload, true);
        if (!is_array($event)) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        $type = (string) ($event['event'] ?? '');
        $data = isset($event['data']) && is_array($event['data']) ? $event['data'] : [];

        // Only refund-style events reach the rollback path. Anything else
        // (charge.success etc.) is silently acked.
        $is_refund_event = in_array($type, [
            'refund.processed',
            'refund.pending',
            'charge.dispute.create',
            'charge.dispute.remind',
            'charge.dispute.resolve',
        ], true);
        if (!$is_refund_event) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        // Refund events nest the originating transaction's metadata under
        // `data.transaction.metadata.order_id` OR carry it via the dispute's
        // transaction. Try both. (Paystack's event schema is inconsistent
        // across older accounts vs newer dashboards.)
        $order_id = 0;
        if (isset($data['transaction']['metadata']['order_id'])) {
            $order_id = (int) $data['transaction']['metadata']['order_id'];
        } elseif (isset($data['metadata']['order_id'])) {
            $order_id = (int) $data['metadata']['order_id'];
        }
        if ($order_id <= 0) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        // Amount: Paystack reports minor units. Some refund events nest
        // under `data.amount`, others under `data.transaction.amount`.
        $amount_minor = 0;
        if (isset($data['amount'])) {
            $amount_minor = (int) $data['amount'];
        } elseif (isset($data['transaction']['amount'])) {
            $amount_minor = (int) $data['transaction']['amount'];
        }

        $orders = new OrderRepository();
        $order_row = $orders->findById($order_id);
        if (!$order_row) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        $expected_minor = CheckoutService::toMinorUnits((float) $order_row->total, (string) $order_row->currency);
        $is_full = $amount_minor > 0 && $expected_minor > 0 && $amount_minor === $expected_minor;
        $refund_amount = $amount_minor > 0 ? $amount_minor / 100.0 : null;
        $reason = $type === 'charge.dispute.create' ? 'paystack_dispute' : 'paystack_refund';

        if ($is_full) {
            if (!$this->refunds()->refundFullOrder($order_id, $reason, $refund_amount)) {
                error_log(sprintf('Sikshya: refundFullOrder failed for order %d (Paystack %s).', $order_id, $type));
                return new WP_REST_Response(['ok' => false, 'message' => 'Refund failed'], 500);
            }
        } else {
            do_action('sikshya_order_partial_refund', $order_id, $refund_amount, $reason, $data);
        }

        return new WP_REST_Response(['ok' => true], 200);
    }

    /**
     * Razorpay webhook handler.
     *
     * Auth: HMAC-SHA256 of raw request body using the webhook secret
     * configured in Razorpay Dashboard. Sent in `x-razorpay-signature`
     * header. `hash_equals` for constant-time comparison.
     *
     * Event matrix (Razorpay docs at
     * https://razorpay.com/docs/webhooks/payloads/payments/):
     *   - `payment.captured` — buyer-return `/checkout/confirm` handles it.
     *   - `payment.refunded` / `refund.processed` / `refund.created` →
     *     refund branch.
     */
    public function razorpay(WP_REST_Request $request): WP_REST_Response
    {
        $settings = $this->plugin->getService('settings');
        $webhook_secret = '';
        if (is_object($settings) && method_exists($settings, 'getSetting')) {
            $webhook_secret = (string) $settings->getSetting('razorpay_webhook_secret', '');
        }
        if ($webhook_secret === '') {
            return new WP_REST_Response(['ok' => false, 'message' => 'Razorpay webhook secret not configured.'], 400);
        }

        $payload = $request->get_body();
        $sig_header = (string) ($_SERVER['HTTP_X_RAZORPAY_SIGNATURE'] ?? '');
        $expected = hash_hmac('sha256', $payload, $webhook_secret);
        if (!hash_equals($expected, $sig_header)) {
            return new WP_REST_Response(['ok' => false, 'message' => 'Invalid signature'], 400);
        }

        $event = json_decode($payload, true);
        if (!is_array($event)) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        $type = (string) ($event['event'] ?? '');
        $payload_data = isset($event['payload']) && is_array($event['payload']) ? $event['payload'] : [];

        $is_refund_event = in_array($type, [
            'payment.refunded',
            'refund.created',
            'refund.processed',
            'refund.failed',
        ], true);
        if (!$is_refund_event) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        // Razorpay's refund.* events nest the originating payment entity
        // separately from the refund entity. Look at both.
        $order_id = 0;
        $payment_entity = $payload_data['payment']['entity'] ?? null;
        if (is_array($payment_entity) && isset($payment_entity['notes']['order_id'])) {
            $order_id = (int) $payment_entity['notes']['order_id'];
        }
        // Fallback: when the original purchase used a payment_link, the
        // notes live on the link itself (we set `notes.order_id` in
        // `createRazorpayPaymentLink`). The refund event carries
        // `payment_link.entity.notes` only on some webhook versions.
        if ($order_id <= 0 && isset($payload_data['payment_link']['entity']['notes']['order_id'])) {
            $order_id = (int) $payload_data['payment_link']['entity']['notes']['order_id'];
        }
        if ($order_id <= 0) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        // Refund amount in minor units. Lives on the refund entity for
        // refund.* events, on the payment entity's `amount_refunded` for
        // payment.refunded.
        $refund_minor = 0;
        $refund_entity = $payload_data['refund']['entity'] ?? null;
        if (is_array($refund_entity) && isset($refund_entity['amount'])) {
            $refund_minor = (int) $refund_entity['amount'];
        } elseif (is_array($payment_entity) && isset($payment_entity['amount_refunded'])) {
            $refund_minor = (int) $payment_entity['amount_refunded'];
        }

        $orders = new OrderRepository();
        $order_row = $orders->findById($order_id);
        if (!$order_row) {
            return new WP_REST_Response(['ok' => true], 200);
        }

        $expected_minor = CheckoutService::toMinorUnits((float) $order_row->total, (string) $order_row->currency);
        $is_full = $refund_minor > 0 && $expected_minor > 0 && $refund_minor === $expected_minor;
        $refund_amount = $refund_minor > 0 ? $refund_minor / 100.0 : null;

        if ($is_full) {
            if (!$this->refunds()->refundFullOrder($order_id, 'razorpay_refund', $refund_amount)) {
                error_log(sprintf('Sikshya: refundFullOrder failed for order %d (Razorpay %s).', $order_id, $type));
                return new WP_REST_Response(['ok' => false, 'message' => 'Refund failed'], 500);
            }
        } else {
            do_action('sikshya_order_partial_refund', $order_id, $refund_amount, 'razorpay_refund', $payload_data);
        }

        return new WP_REST_Response(['ok' => true], 200);
    }

    private function checkout(): CheckoutService
    {
        // Checkout service requires repositories; keep this lightweight for webhook processing.
        return new CheckoutService(
            $this->plugin,
            new OrderRepository(),
            $this->plugin->getService('coupons') instanceof \Sikshya\Database\Repositories\CouponRepository
                ? $this->plugin->getService('coupons')
                : new \Sikshya\Database\Repositories\CouponRepository()
        );
    }

    private function fulfillment(): OrderFulfillmentService
    {
        $course = $this->plugin->getService('course');
        if (!$course instanceof CourseService) {
            throw new \RuntimeException('Course service missing');
        }

        return new OrderFulfillmentService(
            new OrderRepository(),
            new PaymentRepository(),
            $course
        );
    }

    /**
     * Lazy accessor mirroring `fulfillment()` but for the reverse direction.
     */
    private function refunds(): \Sikshya\Commerce\OrderRefundService
    {
        $course = $this->plugin->getService('course');
        if (!$course instanceof CourseService) {
            throw new \RuntimeException('Course service missing');
        }

        return new \Sikshya\Commerce\OrderRefundService(
            new OrderRepository(),
            new PaymentRepository(),
            $course
        );
    }

    private function verifyStripeSignature(string $payload, string $sig_header, string $secret): bool
    {
        if ($sig_header === '' || $payload === '') {
            return false;
        }

        $parts = explode(',', $sig_header);
        $timestamp = '';
        $signatures = [];
        foreach ($parts as $part) {
            $kv = explode('=', trim($part), 2);
            if (count($kv) !== 2) {
                continue;
            }
            if ($kv[0] === 't') {
                $timestamp = $kv[1];
            }
            if ($kv[0] === 'v1') {
                $signatures[] = $kv[1];
            }
        }

        if ($timestamp === '' || $signatures === []) {
            return false;
        }

        // Reject stale signatures (5 minute tolerance).
        $ts = (int) $timestamp;
        if ($ts > 0) {
            $age = abs(time() - $ts);
            if ($age > 300) {
                return false;
            }
        }

        $signed = $timestamp . '.' . $payload;

        // Stripe expects the signing secret as-is (the full `whsec_...` string).
        $expected = hash_hmac('sha256', $signed, $secret, false);
        foreach ($signatures as $sig) {
            if (hash_equals($expected, $sig)) {
                return true;
            }
        }

        return false;
    }

    private function paypalRestBaseFromGlobalTestMode(): string
    {
        $settings = $this->plugin->getService('settings');
        $test = true;
        if (is_object($settings) && method_exists($settings, 'getSetting')) {
            $v = $settings->getSetting('enable_test_mode', true);
            $test = $v === true || $v === 1 || $v === '1' || $v === 'true' || $v === 'yes' || $v === 'on';
        }

        return $test ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
    }

    private function verifyPayPalSignature(
        string $payload,
        string $client_id,
        string $secret,
        string $mode,
        string $webhook_id
    ): bool {
        $transmission_id = isset($_SERVER['HTTP_PAYPAL_TRANSMISSION_ID']) ? (string) $_SERVER['HTTP_PAYPAL_TRANSMISSION_ID'] : '';
        $transmission_time = isset($_SERVER['HTTP_PAYPAL_TRANSMISSION_TIME']) ? (string) $_SERVER['HTTP_PAYPAL_TRANSMISSION_TIME'] : '';
        $transmission_sig = isset($_SERVER['HTTP_PAYPAL_TRANSMISSION_SIG']) ? (string) $_SERVER['HTTP_PAYPAL_TRANSMISSION_SIG'] : '';
        $cert_url = isset($_SERVER['HTTP_PAYPAL_CERT_URL']) ? (string) $_SERVER['HTTP_PAYPAL_CERT_URL'] : '';
        $auth_algo = isset($_SERVER['HTTP_PAYPAL_AUTH_ALGO']) ? (string) $_SERVER['HTTP_PAYPAL_AUTH_ALGO'] : '';

        if ($transmission_id === '' || $transmission_time === '' || $transmission_sig === '' || $cert_url === '' || $auth_algo === '') {
            return false;
        }

        $base = $this->paypalRestBaseFromGlobalTestMode();

        // OAuth token.
        $token_res = wp_remote_post(
            $base . '/v1/oauth2/token',
            [
                'timeout' => 30,
                'headers' => [
                    'Authorization' => 'Basic ' . base64_encode($client_id . ':' . $secret),
                ],
                'body' => ['grant_type' => 'client_credentials'],
            ]
        );
        if (is_wp_error($token_res)) {
            return false;
        }
        $token_body = json_decode((string) wp_remote_retrieve_body($token_res), true);
        $access = is_array($token_body) ? (string) ($token_body['access_token'] ?? '') : '';
        if ($access === '') {
            return false;
        }

        $verify_payload = [
            'auth_algo' => $auth_algo,
            'cert_url' => $cert_url,
            'transmission_id' => $transmission_id,
            'transmission_sig' => $transmission_sig,
            'transmission_time' => $transmission_time,
            'webhook_id' => $webhook_id,
            'webhook_event' => json_decode($payload, true),
        ];
        if (!is_array($verify_payload['webhook_event'])) {
            return false;
        }

        $verify_res = wp_remote_post(
            $base . '/v1/notifications/verify-webhook-signature',
            [
                'timeout' => 30,
                'headers' => [
                    'Authorization' => 'Bearer ' . $access,
                    'Content-Type' => 'application/json',
                ],
                'body' => wp_json_encode($verify_payload),
            ]
        );
        if (is_wp_error($verify_res)) {
            return false;
        }
        $dec = json_decode((string) wp_remote_retrieve_body($verify_res), true);
        $status = is_array($dec) ? (string) ($dec['verification_status'] ?? '') : '';

        return $status === 'SUCCESS';
    }
}
