<?php
/**
 * Abstract API Client Base Class
 *
 * Base class for all third-party API clients providing shared HTTP functionality.
 *
 * @package Sequensy
 * @since 2.0.0
 */

declare(strict_types=1);

namespace Sequensy\Integrations;

use Sequensy\Http\CircuitBreaker;
use Sequensy\Http\HttpStatusCodes;
use WP_Error;

// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
	die;
}

/**
 * Abstract class AbstractApiClient
 *
 * Provides shared functionality for API clients:
 * - HTTP request handling with wp_remote_request
 * - Configurable authentication headers
 * - Standardized error handling
 * - Response parsing
 */
abstract class AbstractApiClient {

	/**
	 * Request timeout in seconds.
	 *
	 * @var int
	 */
	protected int $timeout = 30;

	/**
	 * Default maximum retry attempts.
	 *
	 * @var int
	 */
	protected int $max_retries = 3;

	/**
	 * Base delay in seconds for exponential backoff.
	 *
	 * @var int
	 */
	protected int $retry_base_delay = 1;

	/**
	 * Circuit breaker instance for this client.
	 *
	 * @var CircuitBreaker|null
	 */
	protected ?CircuitBreaker $circuit_breaker = null;

	/**
	 * Get the base URL for API requests.
	 *
	 * @return string Base URL with trailing slash.
	 */
	abstract protected function get_base_url(): string;

	/**
	 * Get authentication headers for API requests.
	 *
	 * @return array Headers array.
	 */
	abstract protected function get_auth_headers(): array;

	/**
	 * Get the error code prefix for WP_Error objects.
	 *
	 * @return string Error code prefix (e.g., 'mailchimp_api_error').
	 */
	abstract protected function get_error_code(): string;

	/**
	 * Parse error message from API response.
	 *
	 * @param array|null $decoded     Decoded JSON response.
	 * @param int        $status_code HTTP status code.
	 * @return string Error message.
	 */
	abstract protected function parse_error_message( ?array $decoded, int $status_code ): string;

	/**
	 * Get the circuit breaker key for this API client.
	 *
	 * Each client should return a unique key identifying the service.
	 * For services with multiple endpoints (e.g., Mailchimp data centers),
	 * include the specific endpoint in the key.
	 *
	 * @return string Circuit key (e.g., 'mailchimp_us6', 'kit_api').
	 */
	abstract protected function get_circuit_key(): string;

	/**
	 * Allowed HTTP methods.
	 *
	 * @var array<string>
	 */
	protected static array $allowed_methods = array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD' );

	/**
	 * Make an API request.
	 *
	 * @param string     $method   HTTP method (GET, POST, PUT, PATCH, DELETE).
	 * @param string     $endpoint API endpoint (without base URL).
	 * @param array|null $data     Optional data to send with request.
	 * @return array|WP_Error Response array or WP_Error on failure.
	 */
	public function request( string $method, string $endpoint, ?array $data = null ): array|WP_Error {
		// Check circuit breaker first.
		if ( null !== $this->circuit_breaker && ! $this->circuit_breaker->canRequest() ) {
			$retry_after = $this->circuit_breaker->getRetryAfter();
			return new WP_Error(
				'circuit_open',
				sprintf(
					'Service %s is temporarily unavailable. Retry after %d seconds.',
					$this->get_circuit_key(),
					$retry_after
				),
				array(
					'retry_after'  => $retry_after,
					'circuit_open' => true,
					'service_key'  => $this->get_circuit_key(),
				)
			);
		}

		// Validate HTTP method.
		$method = strtoupper( $method );
		if ( ! in_array( $method, self::$allowed_methods, true ) ) {
			return new WP_Error(
				'invalid_method',
				sprintf(
					'Invalid HTTP method: %s. Allowed methods: %s',
					$method,
					implode( ', ', self::$allowed_methods )
				)
			);
		}

		// Validate endpoint parameter.
		if ( '' === $endpoint ) {
			return new WP_Error(
				'invalid_endpoint',
				'API endpoint must be a non-empty string.'
			);
		}

		$url = $this->build_url( $endpoint );

		$args = array(
			'method'  => $method,
			'headers' => $this->get_request_headers(),
			'timeout' => $this->timeout,
		);

		if ( $data && in_array( $method, array( 'POST', 'PUT', 'PATCH' ), true ) ) {
			$args['body'] = wp_json_encode( $data );
		}

		$response = wp_remote_request( $url, $args );

		// Handle network-level errors.
		if ( is_wp_error( $response ) ) {
			$this->record_circuit_failure( $response->get_error_message() );
			return $response;
		}

		$result = $this->parse_response( $response );

		// Record result with circuit breaker.
		if ( is_wp_error( $result ) ) {
			$this->record_circuit_failure( $result->get_error_message() );
		} else {
			$this->record_circuit_success();
		}

		return $result;
	}

	/**
	 * Make an API request with automatic retry on transient failures.
	 *
	 * Uses exponential backoff for retries. Only retries on network errors
	 * and specific HTTP status codes (429, 500, 502, 503, 504, etc.)
	 *
	 * @param string     $method      HTTP method (GET, POST, PUT, PATCH, DELETE).
	 * @param string     $endpoint    API endpoint (without base URL).
	 * @param array|null $data        Optional data to send with request.
	 * @param int|null   $max_retries Maximum retry attempts (null uses default).
	 * @return array|WP_Error Response array or WP_Error on failure.
	 */
	public function request_with_retry(
		string $method,
		string $endpoint,
		?array $data = null,
		?int $max_retries = null
	): array|WP_Error {
		$max_retries = $max_retries ?? $this->max_retries;
		$last_error  = null;

		for ( $attempt = 0; $attempt <= $max_retries; $attempt++ ) {
			$result = $this->request( $method, $endpoint, $data );

			// Success - return immediately.
			if ( ! is_wp_error( $result ) ) {
				return $result;
			}

			$last_error = $result;

			// Check if error is retryable.
			if ( ! $this->is_retryable_error( $result ) ) {
				// Non-retryable error - fail immediately.
				return $result;
			}

			// Don't sleep after last attempt.
			if ( $attempt < $max_retries ) {
				$delay = $this->calculate_retry_delay( $attempt, $result );
				sleep( $delay );

				if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
					error_log(
						sprintf(
							'Sequensy Pro API Client: Retrying request to %s'
							. ' (attempt %d/%d) after %ds delay. Error: %s',
							$endpoint,
							$attempt + 2,
							$max_retries + 1,
							$delay,
							$result->get_error_message()
						)
					);
				}
			}
		}

		// All retries exhausted.
		return $last_error;
	}

	/**
	 * Check if an error is retryable.
	 *
	 * @param WP_Error $error Error object.
	 * @return bool True if the error should be retried.
	 */
	protected function is_retryable_error( WP_Error $error ): bool {
		// Network errors (connection failed, timeout) are always retryable.
		$network_error_codes = array(
			'http_request_failed',
			'http_failure',
		);

		if ( in_array( $error->get_error_code(), $network_error_codes, true ) ) {
			return true;
		}

		// Check status code for HTTP errors.
		$data = $error->get_error_data();
		if ( isset( $data['status_code'] ) ) {
			return HttpStatusCodes::isRetryable( (int) $data['status_code'] );
		}

		return false;
	}

	/**
	 * Calculate retry delay using exponential backoff.
	 *
	 * @param int      $attempt Current attempt number (0-indexed).
	 * @param WP_Error $error   The error that triggered the retry.
	 * @return int Delay in seconds.
	 */
	protected function calculate_retry_delay( int $attempt, WP_Error $error ): int {
		// Check for Retry-After header in rate limit responses.
		$data = $error->get_error_data();
		if ( isset( $data['status_code'] ) && HttpStatusCodes::isRateLimited( (int) $data['status_code'] ) ) {
			// Some APIs include retry delay in response - could be extracted here.
			// For now, use a longer delay for rate limits.
			return min( 60, (int) pow( 2, $attempt + 2 ) );
		}

		// Standard exponential backoff: 1s, 2s, 4s, 8s...
		return min( 30, (int) pow( 2, $attempt ) * $this->retry_base_delay );
	}

	/**
	 * Build the full URL for a request.
	 *
	 * @param string $endpoint API endpoint.
	 * @return string Full URL.
	 */
	protected function build_url( string $endpoint ): string {
		return $this->get_base_url() . ltrim( $endpoint, '/' );
	}

	/**
	 * Get all headers for the request.
	 *
	 * Combines authentication headers with default content-type headers.
	 *
	 * @return array Headers array.
	 */
	protected function get_request_headers(): array {
		$default_headers = array(
			'Content-Type' => 'application/json',
		);

		return array_merge( $default_headers, $this->get_auth_headers() );
	}

	/**
	 * Parse the API response.
	 *
	 * @param array $response WP HTTP API response.
	 * @return array|WP_Error Parsed response or error.
	 */
	protected function parse_response( array $response ): array|WP_Error {
		$status_code = wp_remote_retrieve_response_code( $response );
		$body        = wp_remote_retrieve_body( $response );
		$decoded     = json_decode( $body, true );

		// Handle JSON decode errors (e.g., HTML error pages).
		if ( null === $decoded && ! empty( $body ) && JSON_ERROR_NONE !== json_last_error() ) {
			$json_error = json_last_error_msg();

			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				error_log(
					sprintf(
						'Sequensy Pro API Client: JSON decode error (%s) for response with status %d. Body preview: %s',
						$json_error,
						$status_code,
						substr( $body, 0, 200 )
					)
				);
			}

			$decoded = array(
				'raw_body'   => substr( $body, 0, 500 ),
				'json_error' => $json_error,
			);
		}

		if ( $status_code >= 400 ) {
			return $this->create_error_response( $decoded, $status_code );
		}

		return array(
			'status_code' => $status_code,
			'data'        => $decoded,
		);
	}

	/**
	 * Create a WP_Error from an error response.
	 *
	 * @param array|null $decoded     Decoded JSON response.
	 * @param int        $status_code HTTP status code.
	 * @return WP_Error Error object.
	 */
	protected function create_error_response( ?array $decoded, int $status_code ): WP_Error {
		$error_message = $this->parse_error_message( $decoded, $status_code );

		return new WP_Error(
			$this->get_error_code(),
			$error_message,
			array(
				'status_code' => $status_code,
				'response'    => $decoded,
			)
		);
	}

	/**
	 * Validate API credentials.
	 *
	 * Subclasses must implement this to test that credentials are valid.
	 * Typically makes a simple API call (e.g., ping or list accounts).
	 *
	 * @return array|WP_Error Validation result with account info, or WP_Error on failure.
	 */
	abstract public function validate_credentials(): array|WP_Error;

	/**
	 * Create a Basic auth header value.
	 *
	 * @param string $username Username or API key.
	 * @param string $password Password or placeholder.
	 * @return string Base64-encoded auth string.
	 */
	protected function create_basic_auth( string $username, string $password ): string {
		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for HTTP Basic auth header.
		return 'Basic ' . base64_encode( $username . ':' . $password );
	}

	/**
	 * Initialize the circuit breaker for this client.
	 *
	 * Call this in the constructor of concrete API clients to enable
	 * circuit breaker protection.
	 *
	 * @return void
	 */
	protected function init_circuit_breaker(): void {
		$this->circuit_breaker = new CircuitBreaker( $this->get_circuit_key() );
	}

	/**
	 * Record a successful request with the circuit breaker.
	 *
	 * @return void
	 */
	private function record_circuit_success(): void {
		if ( null !== $this->circuit_breaker ) {
			$this->circuit_breaker->recordSuccess();
		}
	}

	/**
	 * Record a failed request with the circuit breaker.
	 *
	 * @param string|null $error_message Optional error message for debugging.
	 * @return void
	 */
	private function record_circuit_failure( ?string $error_message = null ): void {
		if ( null !== $this->circuit_breaker ) {
			$this->circuit_breaker->recordFailure( $error_message );
		}
	}

	/**
	 * Get the circuit breaker instance.
	 *
	 * Useful for debugging and monitoring circuit state.
	 *
	 * @return CircuitBreaker|null Circuit breaker instance or null if not initialized.
	 */
	public function get_circuit_breaker(): ?CircuitBreaker {
		return $this->circuit_breaker;
	}

	/**
	 * Reset the circuit breaker to closed state.
	 *
	 * @return void
	 */
	public function reset_circuit_breaker(): void {
		if ( null !== $this->circuit_breaker ) {
			$this->circuit_breaker->reset();
		}
	}
}
