<?php
/**
 * Base class for every destination.
 *
 * @package Ordsync
 */

defined( 'ABSPATH' ) || exit;

/**
 * Every destination sends the same payload somewhere and returns
 * true or a WP_Error carrying the HTTP status, so the engine can
 * decide whether to retry.
 */
abstract class Ordsync_Destination {

	/**
	 * Settings for this one destination instance.
	 *
	 * @var array
	 */
	protected $settings;

	/**
	 * Which slot of its type this instance is. Slot 0 is the free one.
	 *
	 * @var int
	 */
	protected $index = 0;

	/**
	 * Raw body of the last response, so a destination can inspect it.
	 *
	 * @var string
	 */
	protected $last_body = '';

	/**
	 * Constructor.
	 *
	 * @param array|null $settings Slot settings, or null to load slot 0.
	 * @param int        $index    Slot index.
	 */
	public function __construct( $settings = null, $index = 0 ) {
		$this->index    = (int) $index;
		$this->settings = is_array( $settings )
			? $settings
			: Ordsync_Settings::get( $this->key(), $this->index );
	}

	/**
	 * Stable identifier for this instance.
	 *
	 * Slot 0 keeps the bare type as its id, so orders already marked as sent
	 * before multi-destination existed are still recognised and never sent a
	 * second time.
	 *
	 * @return string
	 */
	public function id() {
		return ( 0 === $this->index ) ? $this->key() : $this->key() . '_' . $this->index;
	}

	/**
	 * Slot index.
	 *
	 * @return int
	 */
	public function index() {
		return $this->index;
	}

	/**
	 * Name to show in the interface and the logs.
	 *
	 * @return string
	 */
	public function display_name() {
		$label = trim( (string) $this->setting( 'label', '' ) );

		if ( '' !== $label ) {
			return $label;
		}

		return ( 0 === $this->index )
			? $this->label()
			: sprintf( '%s %d', $this->label(), $this->index + 1 );
	}

	/**
	 * Conditional rules for this instance.
	 *
	 * @return array
	 */
	public function conditions() {
		$rules = $this->setting( 'conditions', array() );
		return is_array( $rules ) ? $rules : array();
	}

	/**
	 * Whether ALL or ANY rules must match.
	 *
	 * @return string
	 */
	public function condition_match() {
		return 'any' === $this->setting( 'condition_match', 'all' ) ? 'any' : 'all';
	}

	/**
	 * Unique key, matches the settings group.
	 *
	 * @return string
	 */
	abstract public function key();

	/**
	 * Human label.
	 *
	 * @return string
	 */
	abstract public function label();

	/**
	 * Send the payload.
	 *
	 * @param array $payload Order payload.
	 * @return true|WP_Error
	 */
	abstract public function send( array $payload );

	/**
	 * Is this destination switched on and configured?
	 *
	 * @return bool
	 */
	public function is_enabled() {
		return isset( $this->settings['enabled'] ) && 'yes' === $this->settings['enabled'];
	}

	/**
	 * Which order status fires this destination.
	 *
	 * @return string
	 */
	public function trigger_status() {
		return isset( $this->settings['trigger'] ) ? $this->settings['trigger'] : 'processing';
	}

	/**
	 * Read one setting for this instance.
	 *
	 * @param string $name    Key.
	 * @param mixed  $default Fallback.
	 * @return mixed
	 */
	public function setting( $name, $default = '' ) {
		return isset( $this->settings[ $name ] ) ? $this->settings[ $name ] : $default;
	}

	/**
	 * Follow redirects by hand, turning 301/302/303 into GET requests.
	 *
	 * 307 and 308 are the two codes that legitimately keep the method and
	 * body, so those stay as POST.
	 *
	 * @param array|WP_Error $response Response from the first request.
	 * @param array          $args     Original request args.
	 * @param int            $max      Maximum hops.
	 * @return array|WP_Error
	 */
	protected function follow_redirects( $response, array $args, $max = 5 ) {

		for ( $hop = 0; $hop < $max; $hop++ ) {

			if ( is_wp_error( $response ) ) {
				return $response;
			}

			$code = (int) wp_remote_retrieve_response_code( $response );

			if ( ! in_array( $code, array( 301, 302, 303, 307, 308 ), true ) ) {
				return $response;
			}

			$location = wp_remote_retrieve_header( $response, 'location' );

			if ( empty( $location ) ) {
				return $response;
			}

			/*
			 * SSRF guard.
			 *
			 * The shop owner chooses the first URL, but the REMOTE SERVER
			 * chooses every redirect after it. Without this check a hostile
			 * endpoint could bounce us onto 127.0.0.1, the private network, or
			 * a cloud metadata service (169.254.169.254) and read the reply.
			 *
			 * wp_http_validate_url() rejects those, plus non-http(s) schemes.
			 */
			$safe_location = wp_http_validate_url( $location );

			if ( ! $safe_location ) {
				return new WP_Error(
					'ordsync_unsafe_redirect',
					sprintf(
						/* translators: %s: the redirect target that was refused */
						__( 'The destination tried to redirect to an address that is not allowed and was blocked: %s', 'ordercarry-order-automation-for-woocommerce' ),
						esc_url_raw( $location )
					),
					array( 'status' => 400 )
				);
			}

			$location = $safe_location;

			if ( 307 === $code || 308 === $code ) {
				$args['redirection'] = 0;
				$response            = wp_remote_post( $location, $args );
				continue;
			}

			$response = wp_remote_get(
				$location,
				array(
					'timeout'     => isset( $args['timeout'] ) ? $args['timeout'] : 15,
					'redirection' => 0,
					'sslverify'   => true,
					'user-agent'  => isset( $args['user-agent'] ) ? $args['user-agent'] : '',
					'headers'     => array( 'Accept' => '*/*' ),
				)
			);
		}

		return $response;
	}

	/**
	 * Pull the HTTP status back out of one of our WP_Errors.
	 *
	 * Exists because reading it by hand is easy to get wrong, and getting it
	 * wrong is silent: WP_Error::get_error_data() takes an error CODE, not a
	 * data key, so the natural-looking get_error_data( 'status' ) returns
	 * null forever. That made every 4xx look like status 0 -- "the request
	 * never landed" -- so dead URLs were retried three times and Sheets never
	 * showed a single one of its friendly Google hints. One accessor, next to
	 * the code that writes the data, so both readers cannot drift again.
	 *
	 * @param WP_Error $error Error from post().
	 * @return int HTTP status, or 0 when the request never landed.
	 */
	public static function error_status( $error ) {

		if ( ! is_wp_error( $error ) ) {
			return 0;
		}

		$data = $error->get_error_data();

		return ( is_array( $data ) && isset( $data['status'] ) ) ? (int) $data['status'] : 0;
	}

	/**
	 * POST JSON somewhere.
	 *
	 * The whole retry system depends on the WP_Error data below:
	 *   status 0    => network problem, retry
	 *   status 5xx  => their server broke, retry
	 *   status 429  => rate limited, retry
	 *   status 4xx  => bad configuration, do NOT retry
	 *
	 * @param string $url       Target URL.
	 * @param array  $body      Body to encode as JSON.
	 * @param array  $overrides wp_remote_post args to override.
	 * @return true|WP_Error
	 */
	protected function post( $url, array $body, array $overrides = array() ) {

		if ( empty( $url ) ) {
			return new WP_Error(
				'ordsync_no_url',
				__( 'No URL is configured.', 'ordercarry-order-automation-for-woocommerce' ),
				array( 'status' => 400 )
			);
		}

		$args = wp_parse_args(
			$overrides,
			array(
				'timeout'    => 15,
				'blocking'   => true,
				'sslverify'  => true,
				'headers'    => array( 'Content-Type' => 'application/json' ),
				'body'       => wp_json_encode( $body ),
				'user-agent' => 'OrderSync/' . ORDSYNC_VERSION . '; ' . home_url(),
			)
		);

		/*
		 * We follow redirects ourselves.
		 *
		 * Google Apps Script answers /exec with a 302 pointing at
		 * script.googleusercontent.com, and that address REJECTS a POST.
		 * WordPress's own redirect handling re-sends the POST, so every
		 * Google Sheets sync fails with a 400. Following it manually as a
		 * GET is the only thing that works. Verified against a live
		 * deployment on 2026-08-05.
		 */
		$args['redirection'] = 0;

		$this->last_body = '';
		$response        = $this->follow_redirects( wp_remote_post( $url, $args ), $args );

		if ( is_wp_error( $response ) ) {
			return new WP_Error(
				'ordsync_transport',
				$response->get_error_message(),
				array( 'status' => 0 )
			);
		}

		$this->last_body = (string) wp_remote_retrieve_body( $response );
		$code            = (int) wp_remote_retrieve_response_code( $response );

		if ( $code < 200 || $code >= 300 ) {
			$snippet = wp_strip_all_tags( substr( $this->last_body, 0, 200 ) );

			return new WP_Error(
				'ordsync_http',
				sprintf(
					/* translators: 1: HTTP status code, 2: first part of the response body */
					__( 'HTTP %1$d — %2$s', 'ordercarry-order-automation-for-woocommerce' ),
					$code,
					$snippet
				),
				array( 'status' => $code )
			);
		}

		return true;
	}
}
