<?php
/**
 * PeachPay Authorize.net webhook handler.
 *
 * Processes inbound webhook notifications from Authorize.net, verifies the
 * HMAC-SHA512 signature, looks up the WooCommerce order by transaction ID,
 * fetches full transaction details from the Authorize.net API, and dispatches
 * to the appropriate handler based on event type.
 *
 * @package PeachPay
 */

defined( 'PEACHPAY_ABSPATH' ) || exit;

/**
 * Authorize.net webhook endpoint callback.
 *
 * Registered at POST /wp-json/peachpay/v1/authnet/webhook.
 * Uses get_body() (not get_json_params()) so the raw payload is available
 * for HMAC signature verification before any decoding occurs.
 *
 * @param WP_REST_Request $request The incoming REST request.
 * @return WP_REST_Response
 */
function peachpay_rest_api_authnet_webhook( WP_REST_Request $request ) {
	// 1. Get the raw request body — required for HMAC verification.
	$raw_body = $request->get_body();

	// 2. Ensure at least one webhook signature key is configured before attempting
	// verification or processing. Authorize.net credentials can be saved without
	// the webhook key, but unverifiable webhooks must be ignored.
	if ( ! peachpay_authnet_has_webhook_signature_key() ) {
		peachpay_log( 'warning', 'Authnet.webhook.missing_signature_key' );
		return new WP_REST_Response( array( 'message' => 'Webhook signature key not configured' ), 200 );
	}

	// 3. Get the Authorize.net signature header.
	$signature = $request->get_header( 'x_anet_signature' );
	if ( empty( $signature ) ) {
		peachpay_log( 'warning', 'Authnet.webhook.missing_signature' );
		return new WP_REST_Response( array( 'message' => 'Missing signature' ), 400 );
	}

	// 4. Verify the HMAC-SHA512 signature — try test key first, then live key.
	$mode = peachpay_authnet_verify_webhook_signature( $raw_body, $signature );
	if ( null === $mode ) {
		peachpay_log( 'error', 'Authnet.webhook.invalid_signature' );
		return new WP_REST_Response( array( 'message' => 'Invalid signature' ), 403 );
	}

	peachpay_log( 'debug', 'Authnet.webhook.signature_verified | ' . wp_json_encode( array( 'mode' => $mode ) ) );

	// 5. Decode the JSON payload.
	$data = json_decode( $raw_body, true );
	if ( ! is_array( $data ) || ! isset( $data['eventType'], $data['payload']['id'] ) ) {
		peachpay_log( 'warning', 'Authnet.webhook.invalid_payload | ' . wp_json_encode( array( 'raw' => substr( $raw_body, 0, 200 ) ) ) );
		return new WP_REST_Response( array( 'message' => 'Invalid payload' ), 400 );
	}

	// 6. Extract and validate the transaction event payload.
	$txn_id          = sanitize_text_field( wp_unslash( $data['payload']['id'] ) );
	$event_type      = sanitize_text_field( wp_unslash( $data['eventType'] ) );
	$entity_name     = isset( $data['payload']['entityName'] ) ? sanitize_text_field( wp_unslash( $data['payload']['entityName'] ) ) : '';
	$notification_id = isset( $data['notificationId'] ) ? sanitize_text_field( wp_unslash( $data['notificationId'] ) ) : '';

	if ( peachpay_authnet_is_payment_webhook_event( $event_type ) && 'transaction' !== $entity_name ) {
		peachpay_log(
			'warning',
			'Authnet.webhook.invalid_payload_entity | ' . wp_json_encode(
				array(
					'event_type'  => $event_type,
					'entity_name' => $entity_name,
				)
			)
		);
		return new WP_REST_Response( array( 'message' => 'Invalid payload entity' ), 400 );
	}

	peachpay_log( 'info', 'Authnet.webhook.event_received | ' . wp_json_encode( array( 'event_type' => $event_type, 'txn_id' => $txn_id, 'notification_id' => $notification_id ) ) );

	// 7. Look up the WooCommerce order by Authorize.net transaction ID.
	$order = peachpay_authnet_find_order_by_transaction_id( $txn_id );
	if ( ! $order ) {
		// Return 200 to prevent Authorize.net from retrying — this transaction
		// either belongs to a different system or the order was deleted. Do not
		// record notificationId here because idempotency is order-scoped.
		peachpay_log( 'info', 'Authnet.webhook.order_not_found | ' . wp_json_encode( array( 'txn_id' => $txn_id ) ) );
		return new WP_REST_Response( array( 'message' => 'Order not found' ), 200 );
	}

	if ( '' !== $notification_id && peachpay_authnet_order_has_processed_notification( $order, $notification_id ) ) {
		peachpay_log(
			'info',
			'Authnet.webhook.duplicate_notification | ' . wp_json_encode(
				array(
					'order_id'        => $order->get_id(),
					'txn_id'          => $txn_id,
					'notification_id' => $notification_id,
				)
			)
		);
		return new WP_REST_Response( array( 'message' => 'Duplicate notification' ), 200 );
	}

	// 8. Fetch full transaction details directly from the Authorize.net API.
	$txn_details = PeachPay_Authnet::get_transaction_details( $mode, $txn_id );
	if ( ! $txn_details ) {
		peachpay_log( 'error', 'Authnet.webhook.txn_details_fetch_failed | ' . wp_json_encode( array( 'txn_id' => $txn_id, 'mode' => $mode ) ) );
		// Transaction detail lookups can fail transiently; return retryable status and
		// do not mark notificationId processed so Authorize.net can retry delivery.
		return new WP_REST_Response( array( 'message' => 'Could not fetch transaction details' ), 503 );
	}

	// 9. Dispatch based on the event type.
	peachpay_authnet_handle_webhook_event( $order, $event_type, $txn_details );

	if ( '' !== $notification_id ) {
		peachpay_authnet_mark_notification_processed( $order, $notification_id );
	}

	return new WP_REST_Response( array( 'message' => 'OK' ), 200 );
}

/**
 * Determines whether any Authorize.net webhook signature key is configured.
 *
 * @return bool True when at least one mode has a saved webhook signature key.
 */
function peachpay_authnet_has_webhook_signature_key() {
	return ! empty( PeachPay_Authnet_Credentials::get_webhook_signature_key( 'test' ) )
		|| ! empty( PeachPay_Authnet_Credentials::get_webhook_signature_key( 'live' ) );
}

/**
 * Verifies the Authorize.net webhook HMAC-SHA512 signature.
 *
 * Authorize.net signs the raw request body using the merchant's Webhook
 * Signature Key (configured in the MINT dashboard) and sends the result in
 * the X-ANET-Signature header as: sha512=<UPPERCASE_HEX>
 *
 * Because the webhook may arrive while the site is in either test or live mode,
 * this function tries the test key first and then the live key. This tolerates
 * webhooks fired by the sandbox being received on a site temporarily set to
 * live mode (or vice-versa).
 *
 * @param string $payload The raw (un-decoded) request body.
 * @param string $header  The full value of the X-ANET-Signature header.
 * @return string|null Returns 'test' or 'live' to indicate which key matched,
 *                     or null if neither key produced a valid signature.
 */
function peachpay_authnet_verify_webhook_signature( $payload, $header ) {
	$digest = peachpay_authnet_parse_webhook_signature_digest( $header );

	if ( null === $digest ) {
		return null;
	}

	$modes = array( 'test', 'live' );

	foreach ( $modes as $mode ) {
		$key = PeachPay_Authnet_Credentials::get_webhook_signature_key( $mode );

		if ( empty( $key ) ) {
			continue;
		}

		$expected = strtolower( hash_hmac( 'sha512', $payload, $key ) );

		if ( hash_equals( $expected, $digest ) ) {
			return $mode;
		}
	}

	return null;
}

/**
 * Parses and normalizes the Authorize.net webhook signature header.
 *
 * Authorize.net sends X-ANET-Signature as `sha512=<hex digest>`. The prefix is
 * accepted case-insensitively, but only the digest is normalized before the
 * constant-time comparison. This avoids changing the expected lowercase prefix
 * into `SHA512=` while still accepting upper/lower-case hexadecimal digests.
 *
 * @param string $header The full X-ANET-Signature header value.
 * @return string|null Lowercase 128-character SHA-512 hex digest, or null.
 */
function peachpay_authnet_parse_webhook_signature_digest( $header ) {
	$header = trim( (string) $header );

	if ( 1 !== preg_match( '/^sha512=([a-f0-9]{128})$/i', $header, $matches ) ) {
		return null;
	}

	return strtolower( $matches[1] );
}

/**
 * Determines whether an Authorize.net event is a payment transaction event.
 *
 * @param string $event_type Authorize.net event type string.
 * @return bool
 */
function peachpay_authnet_is_payment_webhook_event( $event_type ) {
	return 0 === strpos( (string) $event_type, 'net.authorize.payment.' );
}

/**
 * Determines whether an AuthNet event should refresh WooCommerce payment state.
 *
 * @param string $event_type Authorize.net event type string.
 * @return bool
 */
function peachpay_authnet_event_updates_payment_state( $event_type ) {
	return in_array(
		$event_type,
		array(
			'net.authorize.payment.authcapture.created',
			'net.authorize.payment.capture.created',
			'net.authorize.payment.authorization.created',
			'net.authorize.payment.authonly.created',
			'net.authorize.payment.priorAuthCapture.created',
			'net.authorize.payment.void.created',
			'net.authorize.payment.fraud.held',
			'net.authorize.payment.fraud.approved',
			'net.authorize.payment.fraud.declined',
		),
		true
	);
}

/**
 * Gets processed Authorize.net webhook notification IDs from order meta.
 *
 * @param WC_Order $order The WooCommerce order.
 * @return array
 */
function peachpay_authnet_get_processed_notifications( WC_Order $order ) {
	if ( ! method_exists( $order, 'get_meta' ) ) {
		return array();
	}

	$notification_ids = $order->get_meta( '_pp_authnet_processed_webhook_notification_ids', true );

	if ( ! is_array( $notification_ids ) ) {
		return array();
	}

	return array_values( array_filter( array_map( 'strval', $notification_ids ) ) );
}

/**
 * Checks whether an AuthNet webhook notification was already processed.
 *
 * @param WC_Order $order           The WooCommerce order.
 * @param string   $notification_id Authorize.net notificationId.
 * @return bool
 */
function peachpay_authnet_order_has_processed_notification( WC_Order $order, $notification_id ) {
	$notification_id = (string) $notification_id;

	if ( '' === $notification_id ) {
		return false;
	}

	return in_array( $notification_id, peachpay_authnet_get_processed_notifications( $order ), true );
}

/**
 * Marks an AuthNet webhook notification as processed for this order.
 *
 * Keeps a bounded list to avoid unbounded order-meta growth while still
 * preventing duplicate notes/status changes for recent Authorize.net retries.
 *
 * @param WC_Order $order           The WooCommerce order.
 * @param string   $notification_id Authorize.net notificationId.
 */
function peachpay_authnet_mark_notification_processed( WC_Order $order, $notification_id ) {
	$notification_id = (string) $notification_id;

	if ( '' === $notification_id || ! method_exists( $order, 'update_meta_data' ) ) {
		return;
	}

	$notification_ids = peachpay_authnet_get_processed_notifications( $order );

	if ( in_array( $notification_id, $notification_ids, true ) ) {
		return;
	}

	$notification_ids[] = $notification_id;
	$notification_ids   = array_slice( $notification_ids, -50 );

	$order->update_meta_data( '_pp_authnet_processed_webhook_notification_ids', $notification_ids );

	if ( method_exists( $order, 'save_meta_data' ) ) {
		$order->save_meta_data();
	} elseif ( method_exists( $order, 'save' ) ) {
		$order->save();
	}
}

/**
 * Gets WooCommerce orders for Authorize.net webhook lookups.
 *
 * @param array $args WooCommerce order query args.
 * @return array
 */
if ( ! function_exists( 'peachpay_authnet_get_orders' ) ) {
	function peachpay_authnet_get_orders( $args ) {
		return wc_get_orders( $args );
	}
}

/**
 * Finds a WooCommerce order by its Authorize.net transaction ID.
 *
 * Attempts two lookup strategies:
 *  1. Primary   — WooCommerce order lookup with the native transaction_id field.
 *  2. Fallback  — WooCommerce order lookup scanning the '_pp_authnet_transaction_id'
 *                 order meta key (set during earlier gateway phases).
 *
 * @param string $txn_id The Authorize.net transaction ID.
 * @return WC_Order|null The matched order, or null if not found.
 */
function peachpay_authnet_find_order_by_transaction_id( $txn_id ) {
	// Primary: use WooCommerce's built-in transaction_id index.
	$orders = peachpay_authnet_get_orders(
		array(
			'transaction_id' => $txn_id,
			'limit'          => 1,
		)
	);

	if ( ! empty( $orders ) ) {
		return reset( $orders );
	}

	// Fallback: search by PeachPay-specific authnet transaction meta key.
	$orders = peachpay_authnet_get_orders(
		array(
			'meta_key'     => '_pp_authnet_transaction_id',
			'meta_value'   => $txn_id,
			'meta_compare' => '=',
			'limit'        => 1,
		)
	);

	if ( ! empty( $orders ) ) {
		return reset( $orders );
	}

	return null;
}

/**
 * Dispatches a verified Authorize.net webhook event to the correct handler.
 *
 * Event types handled:
 *  - net.authorize.payment.authcapture.created       → update payment state
 *  - net.authorize.payment.capture.created           → update payment state
 *  - net.authorize.payment.authorization.created     → update payment state (on-hold)
 *  - net.authorize.payment.authonly.created          → compatibility alias
 *  - net.authorize.payment.priorAuthCapture.created  → update payment state
 *  - net.authorize.payment.void.created              → update payment state (cancelled)
 *  - net.authorize.payment.fraud.*                   → update payment state
 *  - net.authorize.payment.refund.created            → add order note only (refund
 *                                                       was already processed via WC)
 *  - (default)                                       → add order note for visibility
 *
 * @param WC_Order $order        The WooCommerce order for this transaction.
 * @param string   $event_type  The Authorize.net event type string.
 * @param array    $txn_details The full transaction array from Authorize.net.
 */
function peachpay_authnet_handle_webhook_event( WC_Order $order, $event_type, array $txn_details ) {
	$data = array( 'transaction_details' => $txn_details );

	if ( peachpay_authnet_event_updates_payment_state( $event_type ) ) {
		peachpay_log(
			'info',
			'Authnet.webhook.calculate_payment_state | ' . wp_json_encode(
				array(
					'event_type' => $event_type,
					'order_id'   => $order->get_id(),
				)
			)
		);
		PeachPay_Authnet::calculate_payment_state( $order, $data );
		return;
	}

	switch ( $event_type ) {
		case 'net.authorize.payment.refund.created':
			// Refunds are typically initiated from the WooCommerce admin panel and
			// processed synchronously in process_refund(). The webhook arriving here
			// is a confirmation — add a note but do not alter order status to avoid
			// double-processing.
			$txn_id = isset( $txn_details['transId'] ) ? sanitize_text_field( $txn_details['transId'] ) : '';
			$amount = isset( $txn_details['settleAmount'] ) ? floatval( $txn_details['settleAmount'] ) : 0;

			peachpay_log(
				'info',
				'Authnet.webhook.refund_notification | ' . wp_json_encode(
					array(
						'order_id' => $order->get_id(),
						'txn_id'   => $txn_id,
					)
				)
			);

			$order->add_order_note(
				sprintf(
					// translators: %1$s: Payment method title. %2$s: Refund amount. %3$s: Transaction ID.
					esc_html__( 'Authorize.net %1$s refund confirmed via webhook for %2$s. (Transaction Id: %3$s)', 'peachpay-for-woocommerce' ),
					esc_html( $order->get_payment_method_title() ),
					esc_html( wc_price( $amount, array( 'currency' => $order->get_currency() ) ) ),
					esc_html( $txn_id )
				)
			);
			break;

		default:
			// Unknown or unsupported event type — log it and add an order note so
			// merchants can see it in WooCommerce without triggering any state change.
			peachpay_log(
				'info',
				'Authnet.webhook.unhandled_event | ' . wp_json_encode(
					array(
						'event_type' => $event_type,
						'order_id'   => $order->get_id(),
					)
				)
			);

			$order->add_order_note(
				sprintf(
					// translators: %s: Authorize.net event type.
					esc_html__( 'Authorize.net webhook received: %s', 'peachpay-for-woocommerce' ),
					esc_html( $event_type )
				)
			);
			break;
	}
}
