<?php // phpcs:ignore WordPress.Files.FileName.NotHyphenatedLowercase, WordPress.Files.FileName.InvalidClassFileName
/**
 * WooPayments Charge Service
 *
 * Handles off-session WooPayments charges initiated by QPilot via the
 * `wc-autoshipcloud/v1/woopayments/charge` REST endpoint.
 *
 * QPilot has no direct merchant API keys for WooPayments (Automattic operates
 * the Stripe Connect platform), so charges must be performed in-process on the
 * merchant's WordPress site using `\WC_Payments::get_payments_api_client()`.
 *
 * @package Autoship Cloud
 * @since 2.13.0
 */

namespace Autoship\Modules\Payments\Services;

use Autoship\Services\Logging\Logger;

/**
 * Performs WooPayments PaymentIntent off-session charges on behalf of QPilot.
 *
 * @package Autoship\Modules\Payments\Services
 * @since 2.13.0
 */
class WooPaymentsChargeService {

	/**
	 * The required fields on the incoming charge request body.
	 *
	 * @var string[]
	 */
	private const REQUIRED_FIELDS = array(
		'scheduledOrderId',
		'processingId',
		'amountCents',
		'currency',
		'paymentMethodId',
		'customerId',
		'orderNumber',
	);

	/**
	 * Idempotency cache TTL — 24 hours, in seconds.
	 *
	 * @var int
	 */
	private const IDEMPOTENCY_TTL = 86400;

	/**
	 * The WooPayments API client wrapper.
	 *
	 * @var WooPaymentsApiClient
	 */
	private WooPaymentsApiClient $api_client;

	/**
	 * Constructor.
	 *
	 * @param WooPaymentsApiClient $api_client The WCPay API client wrapper.
	 */
	public function __construct( WooPaymentsApiClient $api_client ) {
		$this->api_client = $api_client;
	}

	/**
	 * Look up the current status of a WooPayments PaymentIntent. Used by
	 * QPilot after a customer completes 3DS via the email link so QPilot can
	 * confirm the renewal and close the processing cycle.
	 *
	 * @param string $intent_id The PaymentIntent ID (`pi_...`).
	 *
	 * @return array The intent-status payload — see WooPaymentsApiClient::get_intent_status().
	 */
	public function get_intent_status( string $intent_id ): array {
		return $this->api_client->get_intent_status( $intent_id );
	}

	/**
	 * Validate and process a WooPayments charge request.
	 *
	 * Returns a structured array that the REST callback serializes as JSON. The
	 * shape is contractually shared with QPilot's `WooPaymentsChargeResponse`
	 * DTO — fields are camelCase, all keys always present.
	 *
	 * @param array $payload The decoded JSON body from QPilot.
	 *
	 * @return array The response payload.
	 */
	public function charge( array $payload ): array {
		$validation_error = $this->validate( $payload );
		if ( null !== $validation_error ) {
			return $this->build_response( 'failed', null, null, false, null, 'invalid_request', $validation_error, null );
		}

		if ( ! $this->api_client->is_available() ) {
			return $this->build_response(
				'failed',
				null,
				null,
				false,
				null,
				'woopayments_inactive',
				'WooPayments plugin is not active or the API client is not available on this site.',
				null
			);
		}

		$processing_id = (string) $payload['processingId'];

		// Idempotency: return the cached response if a prior call for the same
		// processing ID already resolved.
		$cached = $this->lookup_cached_response( $processing_id );
		if ( null !== $cached ) {
			Logger::log( 'WooPayments Charge', sprintf( 'Returning cached response for processingId=%s', $processing_id ) );
			return $cached;
		}

		Logger::log(
			'WooPayments Charge',
			sprintf(
				'Charging SOID=%s processingId=%s amount=%d %s pm=%s cus=%s',
				$payload['scheduledOrderId'],
				$processing_id,
				$payload['amountCents'],
				strtoupper( (string) $payload['currency'] ),
				$payload['paymentMethodId'],
				$payload['customerId']
			)
		);

		$intent_result = $this->api_client->create_off_session_intent(
			(int) $payload['amountCents'],
			(string) $payload['currency'],
			(string) $payload['paymentMethodId'],
			(string) $payload['customerId'],
			(int) $payload['orderNumber'],
			$this->build_metadata( $payload ),
			isset( $payload['returnUrl'] ) && is_string( $payload['returnUrl'] ) ? $payload['returnUrl'] : ''
		);

		// Link the intent/charge to the WC order so WCPay's Transactions list
		// and webhook handlers can resolve the order from the intent ID.
		// Skipped on hard failures (no intent created), but applied to both
		// succeeded and requires_action states so the order is findable when
		// the customer eventually completes 3DS.
		if ( ! empty( $intent_result['intentId'] ) ) {
			$this->link_intent_to_order( (int) $payload['orderNumber'], $intent_result );
		}

		$response = $this->map_intent_to_response( $intent_result );

		// Cache the resolved response so subsequent retries with the same
		// processingId don't double-charge.
		$this->cache_response( $processing_id, $response );

		return $response;
	}

	/**
	 * Validate the incoming payload.
	 *
	 * @param array $payload The decoded JSON body.
	 *
	 * @return string|null A human-readable error message, or null if valid.
	 */
	private function validate( array $payload ): ?string {
		foreach ( self::REQUIRED_FIELDS as $field ) {
			if ( ! array_key_exists( $field, $payload ) || '' === $payload[ $field ] || null === $payload[ $field ] ) {
				return "Missing required field: {$field}";
			}
		}

		if ( ! is_numeric( $payload['amountCents'] ) || (int) $payload['amountCents'] <= 0 ) {
			return 'Field amountCents must be a positive integer.';
		}

		if ( ! is_numeric( $payload['orderNumber'] ) || (int) $payload['orderNumber'] <= 0 ) {
			return 'Field orderNumber must be a positive integer (the WooCommerce order ID).';
		}

		return null;
	}

	/**
	 * Build the metadata payload attached to the WCPay PaymentIntent.
	 *
	 * @param array $payload The original request payload.
	 *
	 * @return array
	 */
	private function build_metadata( array $payload ): array {
		$metadata = array(
			'qpilot_scheduled_order_id' => (string) $payload['scheduledOrderId'],
			'qpilot_processing_id'      => (string) $payload['processingId'],
			'payment_type'              => 'recurring',
		);

		if ( ! empty( $payload['orderNumber'] ) ) {
			$metadata['order_number'] = (string) $payload['orderNumber'];
		}

		if ( ! empty( $payload['description'] ) ) {
			$metadata['description'] = (string) $payload['description'];
		}

		if ( isset( $payload['metadata'] ) && is_array( $payload['metadata'] ) ) {
			foreach ( $payload['metadata'] as $key => $value ) {
				$metadata[ (string) $key ] = (string) $value;
			}
		}

		return $metadata;
	}

	/**
	 * Persist the PaymentIntent/charge identifiers onto the WC order so that
	 * WCPay's Transactions list, refund handlers, and Stripe webhook
	 * processors can resolve the order from the intent ID.
	 *
	 * Prefers WCPay's `Order_Service` when available (it knows the canonical
	 * meta-key names across WCPay versions), and falls back to direct WC order
	 * meta writes with the well-known keys WCPay reads internally.
	 *
	 * @param int   $wc_order_id   The WooCommerce order ID.
	 * @param array $intent_result The normalized intent result from the API client.
	 *
	 * @return void
	 */
	private function link_intent_to_order( int $wc_order_id, array $intent_result ): void {
		$intent_id = isset( $intent_result['intentId'] ) ? (string) $intent_result['intentId'] : '';
		$charge_id = isset( $intent_result['chargeId'] ) ? (string) $intent_result['chargeId'] : '';
		$status    = isset( $intent_result['status'] ) ? (string) $intent_result['status'] : '';

		if ( '' === $intent_id ) {
			return;
		}

		// Preferred path: ask WCPay's Order_Service to write the canonical
		// intent/charge meta. The remaining fields (mode, ...) go through
		// direct meta writes below regardless of which path runs.
		$service_handled_intent_meta = false;
		if ( class_exists( '\WC_Payments' ) && method_exists( '\WC_Payments', 'get_order_service' ) ) {
			try {
				$service = \WC_Payments::get_order_service();
				if ( is_object( $service ) ) {
					if ( method_exists( $service, 'set_intent_id_for_order' ) ) {
						$service->set_intent_id_for_order( $wc_order_id, $intent_id );
					}
					if ( '' !== $charge_id && method_exists( $service, 'set_charge_id_for_order' ) ) {
						$service->set_charge_id_for_order( $wc_order_id, $charge_id );
					}
					if ( '' !== $status && method_exists( $service, 'set_intention_status_for_order' ) ) {
						$service->set_intention_status_for_order( $wc_order_id, $status );
					}
					$service_handled_intent_meta = true;
				}
			} catch ( \Throwable $e ) {
				Logger::trace( 'WooPayments Charge', sprintf( 'Order_Service unavailable for SOID linkage, falling back to direct meta: %s', $e->getMessage() ) );
			}
		}

		if ( ! function_exists( 'wc_get_order' ) ) {
			return;
		}

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

		// Fallback for intent/charge/status meta if Order_Service was unavailable.
		if ( ! $service_handled_intent_meta ) {
			$order->update_meta_data( '_intent_id', $intent_id );
			if ( '' !== $charge_id ) {
				$order->update_meta_data( '_charge_id', $charge_id );
			}
			if ( '' !== $status ) {
				$order->update_meta_data( '_intention_status', $status );
			}
		}

		// `_wcpay_mode` records the site's WCPay test/live mode at the moment
		// of charge — used by WCPay admin to flag test-mode orders with the
		// "WooPayments was in test mode" banner. The Stripe `charge.updated`
		// webhook can't infer this; it has to be captured locally at charge time.
		$mode_value = $this->resolve_wcpay_mode();
		if ( null !== $mode_value ) {
			$order->update_meta_data( '_wcpay_mode', $mode_value );
		}

		$order->save();
	}

	/**
	 * Resolve the WCPay test/live mode for the current request.
	 *
	 * @return string|null `'test'` or `'live'`, or null when WCPay isn't loaded
	 *                    or its mode resolver throws.
	 */
	private function resolve_wcpay_mode(): ?string {
		if ( ! class_exists( '\WC_Payments' ) || ! method_exists( '\WC_Payments', 'mode' ) ) {
			return null;
		}

		try {
			$mode = \WC_Payments::mode();
			if ( is_object( $mode ) && method_exists( $mode, 'is_test' ) ) {
				return $mode->is_test() ? 'test' : 'live';
			}
		} catch ( \Throwable $e ) {
			Logger::trace( 'WooPayments Charge', sprintf( 'Could not resolve WCPay mode for order linkage: %s', $e->getMessage() ) );
		}

		return null;
	}

	/**
	 * Map a normalized intent result onto the contract response shape.
	 *
	 * @param array $intent The normalized intent result from the API client.
	 *
	 * @return array
	 */
	private function map_intent_to_response( array $intent ): array {
		$status = strtolower( (string) ( $intent['status'] ?? '' ) );

		if ( 'requires_action' === $status && ! empty( $intent['nextActionUrl'] ) ) {
			return $this->build_response(
				$status,
				$intent['intentId'] ?? null,
				$intent['chargeId'] ?? null,
				true,
				$intent['nextActionUrl'],
				$intent['errorCode'] ?? null,
				$intent['errorMessage'] ?? null,
				$intent['declineCode'] ?? null
			);
		}

		if ( 'succeeded' === $status ) {
			return $this->build_response(
				'succeeded',
				$intent['intentId'] ?? null,
				$intent['chargeId'] ?? null,
				false,
				null,
				null,
				null,
				null,
				true
			);
		}

		// Anything else (`requires_payment_method`, `failed`, empty, etc.) is a decline.
		return $this->build_response(
			'' === $status ? 'failed' : $status,
			$intent['intentId'] ?? null,
			$intent['chargeId'] ?? null,
			false,
			null,
			$intent['errorCode'] ?? null,
			$intent['errorMessage'] ?? 'WooPayments charge did not succeed.',
			$intent['declineCode'] ?? null
		);
	}

	/**
	 * Construct the contract response payload.
	 *
	 * @param string      $status          The status string.
	 * @param string|null $intent_id       The intent ID.
	 * @param string|null $charge_id       The charge ID.
	 * @param bool        $requires_action Whether 3DS is required.
	 * @param string|null $redirect_url    The 3DS redirect URL.
	 * @param string|null $error_code      The error code.
	 * @param string|null $error_message   The error message.
	 * @param string|null $decline_code    The decline code.
	 * @param bool        $success         Whether the operation succeeded.
	 *
	 * @return array
	 */
	private function build_response(
		string $status,
		?string $intent_id,
		?string $charge_id,
		bool $requires_action,
		?string $redirect_url,
		?string $error_code,
		?string $error_message,
		?string $decline_code,
		bool $success = false
	): array {
		return array(
			'success'        => $success,
			'status'         => $status,
			'intentId'       => $intent_id,
			'chargeId'       => $charge_id,
			'requiresAction' => $requires_action,
			'redirectUrl'    => $redirect_url,
			'errorCode'      => $error_code,
			'errorMessage'   => $error_message,
			'declineCode'    => $decline_code,
		);
	}

	/**
	 * Look up a cached response for a given processing ID.
	 *
	 * @param string $processing_id The QPilot processing ID.
	 *
	 * @return array|null
	 */
	private function lookup_cached_response( string $processing_id ): ?array {
		$cached = get_transient( $this->idempotency_key( $processing_id ) );

		return is_array( $cached ) ? $cached : null;
	}

	/**
	 * Cache a resolved response keyed on the processing ID for idempotency.
	 *
	 * @param string $processing_id The QPilot processing ID.
	 * @param array  $response      The resolved response.
	 *
	 * @return void
	 */
	private function cache_response( string $processing_id, array $response ): void {
		set_transient( $this->idempotency_key( $processing_id ), $response, self::IDEMPOTENCY_TTL );
	}

	/**
	 * Build the transient key used for idempotency.
	 *
	 * @param string $processing_id The QPilot processing ID.
	 *
	 * @return string
	 */
	private function idempotency_key( string $processing_id ): string {
		return 'autoship_woopayments_charge_' . md5( $processing_id );
	}
}
