<?php
/**
 * Class BatchableApiClient
 *
 * @package Automattic\WCShipping
 */

namespace Automattic\WCShipping\Bulk;

use Automattic\WCShipping\Connect\WC_Connect_API_Client_Live;
use WP_Error;

/**
 * Subclass of WC_Connect_API_Client_Live that adds parallel-dispatch helpers for batch flows.
 *
 * Lives in src/ (per project conventions) but extends the legacy classes/ Live client so the
 * Jetpack auth machinery (request_body, request_headers, HMAC signing) can be reused via
 * protected inheritance instead of being re-implemented.
 */
class BatchableApiClient extends WC_Connect_API_Client_Live {

	/**
	 * Concurrency cap for the parallel batch dispatch.
	 *
	 * Tuned to stay within EasyPost's per-second rate limits and to avoid stampeding upstream
	 * carriers with one site's traffic. Conservative for v1; lift after we measure.
	 */
	private const MULTI_CONCURRENCY = 5;

	/**
	 * Per-request HTTP timeout, in seconds. Matches the single-order request() default.
	 */
	private const REQUEST_TIMEOUT = 60;

	/**
	 * Get label rates for many orders in one parallel dispatch.
	 *
	 * @param array $payloads Numerically-indexed list of rate-quote payloads, in the same shape
	 *                        each element passed to get_label_rates() expects.
	 *
	 * @return array Array of decoded responses (object) or WP_Error, aligned with input indices.
	 */
	public function get_label_rates_batch( array $payloads ) {
		return $this->dispatch_post_batch( 'shipping/label/rates', $payloads );
	}

	/**
	 * Purchase labels for many shipments in one parallel dispatch.
	 *
	 * Note: each payload becomes its own per-order BillingDaddy purchase, which means the
	 * merchant sees N Stripe charges. Prefer `send_grouped_label_batch_request()` for the
	 * "one batch = one charge" flow; this method is retained for the rate-quote sibling
	 * (`get_label_rates_batch`) and for any future parallel-dispatch label paths.
	 *
	 * @param array $payloads Numerically-indexed list of shipping-label purchase payloads, in the
	 *                        same shape each element passed to send_shipping_label_request() expects.
	 *
	 * @return array Array of decoded responses (object) or WP_Error, aligned with input indices.
	 */
	public function purchase_labels_batch( array $payloads ) {
		return $this->dispatch_post_batch( 'shipping/label', $payloads );
	}

	/**
	 * Send a single grouped batch label-purchase request to the Connect Server. The
	 * server flattens packages across all shipments into one BillingDaddy purchase, so
	 * the merchant sees one Stripe charge with N line items rather than N per-label
	 * charges. This is the "one batch = one charge" path consumed by the bulk label flow.
	 *
	 * Body shape sent on the wire:
	 *   `{ async, email_receipt, payment_method_id, shipments: [{ order_id, origin,
	 *      destination, packages, shipment_options, is_return }] }`
	 * Only one HTTP call is made — no parallel dispatch, no per-order fan-out.
	 *
	 * @param array $body Multi-shipment payload. Must contain a non-empty `shipments`
	 *                    array and a non-empty `payment_method_id`.
	 *
	 * @return object|WP_Error Decoded per-order response map or a transport-level error.
	 */
	public function send_grouped_label_batch_request( array $body ) {
		if ( empty( $body['payment_method_id'] ) ) {
			return new WP_Error(
				'wcc_missing_payment_method',
				__( 'A payment method is required to purchase labels. Set one in WooCommerce Shipping settings.', 'woocommerce-shipping' )
			);
		}
		if ( empty( $body['shipments'] ) || ! is_array( $body['shipments'] ) ) {
			return new WP_Error(
				'wcc_empty_batch',
				__( 'Cannot purchase a batch with no shipments.', 'woocommerce-shipping' )
			);
		}

		return $this->request( 'POST', '/shipping/labels/batch', $body );
	}

	/**
	 * Generic POST batch dispatcher.
	 *
	 * Builds one HTTP request per payload and fires them all via the Requests library bundled
	 * with WordPress, capped at MULTI_CONCURRENCY in flight. Each response is decoded with the
	 * same JSON + error mapping as the single-order request() in the parent class.
	 *
	 * @param string $path     Relative Connect Server path (no leading slash, e.g. "shipping/label").
	 * @param array  $payloads Numerically-indexed list of body payloads.
	 *
	 * @return array Array of decoded responses (object) or WP_Error, aligned with input indices.
	 */
	private function dispatch_post_batch( string $path, array $payloads ) {
		if ( empty( $payloads ) ) {
			return array();
		}

		// Same Jetpack guards as request(), surfaced once for the whole batch.
		if ( ! class_exists( '\Automattic\Jetpack\Connection\Manager' ) && ! class_exists( '\Automattic\Jetpack\Connection\Tokens' ) ) {
			$error = new WP_Error(
				'jetpack_data_class_not_found',
				__( 'Unable to send request to WooCommerce Shipping server. Jetpack_Data was not found.', 'woocommerce-shipping' )
			);
			return array_fill_keys( array_keys( $payloads ), $error );
		}

		if ( ! method_exists( '\Automattic\Jetpack\Connection\Manager', 'get_access_token' ) && ! method_exists( '\Automattic\Jetpack\Connection\Tokens', 'get_access_token' ) ) {
			$error = new WP_Error(
				'jetpack_data_get_access_token_not_found',
				__( 'Unable to send request to WooCommerce Shipping server. Jetpack connection does not implement get_access_token.', 'woocommerce-shipping' )
			);
			return array_fill_keys( array_keys( $payloads ), $error );
		}

		$base_url = trailingslashit( WOOCOMMERCE_CONNECT_SERVER_URL );
		$base_url = apply_filters( 'wcshipping_server_url', $base_url );
		$url      = trailingslashit( $base_url ) . ltrim( $path, '/' );

		// Worst-case wall time is ceil(N/concurrency) * REQUEST_TIMEOUT when upstream stalls,
		// so scale the PHP time limit accordingly to avoid termination mid-batch.
		if ( function_exists( 'wc_set_time_limit' ) ) {
			$batch_count = (int) ceil( count( $payloads ) / self::MULTI_CONCURRENCY );
			wc_set_time_limit( ( $batch_count * self::REQUEST_TIMEOUT ) + 10 );
		}

		// Resolve per-request options through the same filter the single-order path uses
		// (wcshipping_request_args) so existing integrations adjusting timeout/etc. apply here too.
		$request_options = $this->resolve_batch_request_options();

		// Build per-payload Requests-compatible specs. Headers are built per request because the
		// signature uses a fresh nonce/timestamp from `request_headers()`; sharing one set across
		// the rolling-concurrency dispatch would reuse the same nonce and risk replay rejection.
		$requests    = array();
		$prep_errors = array();
		foreach ( $payloads as $index => $payload ) {
			if ( ! is_array( $payload ) ) {
				$prep_errors[ $index ] = new WP_Error(
					'request_body_should_be_array',
					__( 'Unable to send request to WooCommerce Shipping server. Body must be an array.', 'woocommerce-shipping' )
				);
				continue;
			}

			$body = $this->request_body( $payload );
			$body = wp_json_encode( apply_filters( 'wcshipping_api_client_body', $body ) );
			if ( ! $body ) {
				$prep_errors[ $index ] = new WP_Error(
					'unable_to_json_encode_body',
					__( 'Unable to encode body for request to WooCommerce Shipping server.', 'woocommerce-shipping' )
				);
				continue;
			}

			$headers = $this->request_headers();
			if ( is_wp_error( $headers ) ) {
				$prep_errors[ $index ] = $headers;
				continue;
			}

			$requests[ $index ] = array(
				'url'     => $url,
				'type'    => 'POST',
				'headers' => $headers,
				'data'    => $body,
				'options' => $request_options,
			);
		}

		// Dispatch in parallel via the Requests library.
		$multi_responses = array();
		if ( ! empty( $requests ) ) {
			$requests_class  = class_exists( '\WpOrg\Requests\Requests' )
				? '\WpOrg\Requests\Requests'
				: '\Requests';
			$multi_responses = $requests_class::request_multiple(
				$requests,
				array(
					'multiple_concurrency' => self::MULTI_CONCURRENCY,
				)
			);
		}

		// Parse each response with the same logic as the parent's request().
		$results = array();
		foreach ( $payloads as $index => $payload ) {
			if ( isset( $prep_errors[ $index ] ) ) {
				$results[ $index ] = $prep_errors[ $index ];
				continue;
			}
			$results[ $index ] = $this->parse_multi_response( $multi_responses[ $index ] ?? null );
		}

		return $results;
	}

	/**
	 * Parse a single Requests-library response into the same shape request() returns.
	 *
	 * Mirrors the JSON-decode + error-mapping branches of the parent's request() method.
	 * Lifted here so the parallel path produces results indistinguishable from the sequential
	 * one.
	 *
	 * @param mixed $response Either a Requests response object, an exception, or null.
	 *
	 * @return object|WP_Error
	 */
	private function parse_multi_response( $response ) {
		if ( $response instanceof \WpOrg\Requests\Exception
			|| ( class_exists( '\Requests_Exception' ) && $response instanceof \Requests_Exception ) ) {
			return new WP_Error(
				'wcc_server_request_failed',
				$response->getMessage()
			);
		}

		if ( ! is_object( $response ) || ! isset( $response->status_code ) ) {
			return new WP_Error(
				'wcc_server_no_response',
				__( 'No response received from the WooCommerce Shipping server.', 'woocommerce-shipping' )
			);
		}

		$response_code = (int) $response->status_code;

		// Mirror the live client's content-type guard. Non-JSON error responses (e.g. an HTML 502
		// page from an upstream proxy) surface as `wcc_server_error` with the response code,
		// instead of silently producing a null decode that callers cannot distinguish from an
		// empty success body.
		$content_type = isset( $response->headers['content-type'] ) ? (string) $response->headers['content-type'] : '';
		if ( 200 !== $response_code && false === strpos( $content_type, 'application/json' ) ) {
			return new WP_Error(
				'wcc_server_error',
				sprintf(
					/* translators: %d: HTTP response code */
					__( 'Error: The WooCommerce Shipping server returned HTTP code: %d', 'woocommerce-shipping' ),
					$response_code
				),
				array( 'response_status_code' => $response_code )
			);
		}

		$decoded = ! empty( $response->body ) ? json_decode( $response->body ) : null;

		if ( 200 !== $response_code ) {
			if ( empty( $decoded ) ) {
				return new WP_Error(
					'wcc_server_empty_response',
					sprintf(
						/* translators: %d: HTTP response code */
						__( 'Error: The WooCommerce Shipping server returned ( %d ) and an empty response body.', 'woocommerce-shipping' ),
						$response_code
					),
					array( 'response_status_code' => $response_code )
				);
			}

			$error   = property_exists( $decoded, 'error' ) ? $decoded->error : '';
			$code    = property_exists( $decoded, 'code' ) ? $decoded->code : '';
			$message = property_exists( $decoded, 'message' ) ? $decoded->message : '';
			$data    = property_exists( $decoded, 'data' ) ? (array) $decoded->data : array();

			$data['response_status_code'] = $response_code;

			if ( 'missing_upsdap_terms_of_service_acceptance' === $code ) {
				$data['status'] = $response_code;
				return new WP_Error( $code, $message, $data );
			}

			return new WP_Error(
				'wcc_server_error_response',
				sprintf(
					/* translators: %1$s: error code, %2$s: error message, %3$d: HTTP response code */
					__( 'Error: The WooCommerce Shipping server returned: %1$s %2$s ( %3$d )', 'woocommerce-shipping' ),
					$error,
					$message,
					$response_code
				),
				$data
			);
		}

		return $decoded;
	}

	/**
	 * Build the per-request Requests-library options array, honoring `wcshipping_request_args`.
	 *
	 * The single-order path runs `wp_remote_request()` with an args array that integrations can
	 * tweak via `wcshipping_request_args`. The batch path uses `Requests::request_multiple()`,
	 * which takes a different shape, so we pass the same args through the filter and then map
	 * the keys that survive (timeout today; extend as integration needs surface).
	 *
	 * @return array Options array suitable for a single Requests-library request.
	 */
	private function resolve_batch_request_options(): array {
		$args = array(
			'method'      => 'POST',
			'redirection' => 0,
			'compress'    => true,
			'timeout'     => self::REQUEST_TIMEOUT,
		);
		$args = apply_filters( 'wcshipping_request_args', $args );

		$timeout = isset( $args['timeout'] ) ? (int) $args['timeout'] : self::REQUEST_TIMEOUT;

		return array(
			'timeout' => $timeout > 0 ? $timeout : self::REQUEST_TIMEOUT,
		);
	}
}