<?php
/**
 * WordPress.org plugins/themes API client.
 *
 * Only plugin/theme slugs are ever transmitted. No site data is sent.
 * See readme.txt "External services" for the disclosure.
 *
 * ---------------------------------------------------------------------------
 * BEING A GOOD CITIZEN OF api.wordpress.org
 *
 * This plugin reads the public directory record of every plugin on the site,
 * once a day. Done naively — one request per plugin, cached per subsite — a
 * 40-plugin site would make 40 requests a day, and a 500-subsite network would
 * make 20,000 for byte-identical answers. That is the kind of traffic that gets
 * a plugin blocked, and it would be entirely our fault.
 *
 * Four things prevent it:
 *
 *  1. BATCHING. api.wordpress.org's plugin_information action accepts
 *     request[slugs][] and answers for many plugins in ONE request, returning a
 *     map keyed by slug. Closed plugins keep their full closure payload
 *     (error/closed/closed_date/reason_text) in batch mode, so nothing is lost.
 *     A 40-plugin site is one request, not forty.
 *
 *  2. FIELD TRIMMING. The default response carries sections, screenshots,
 *     banners, ratings and the full changelog — tens of kilobytes per plugin
 *     that we never read. Explicitly switching them off cuts the payload by
 *     roughly an order of magnitude.
 *
 *  3. NETWORK-SCOPED CACHE. The answer for a slug is identical on every subsite,
 *     so the cache is network-scoped (see UPilot_Store). One network, one cache.
 *
 *  4. BACKOFF. 429 and 5xx responses set a cool-off that every later call
 *     respects, honouring Retry-After when it is sent. The circuit breaker trips
 *     after repeated transport failures. When we are backing off we fall back to
 *     cached data silently — an API hiccup is never shown to the user as an
 *     error, because a plugin whose whole pitch is honesty about certainty must
 *     not cry wolf.
 * ---------------------------------------------------------------------------
 *
 * @package Upgrade_Pilot
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class UPilot_WPOrg_Client
 */
class UPilot_WPOrg_Client {

	const PLUGIN_INFO_URL = 'https://api.wordpress.org/plugins/info/1.2/';
	const THEME_INFO_URL  = 'https://api.wordpress.org/themes/info/1.2/';

	const CACHE_TTL      = DAY_IN_SECONDS;
	const NEGATIVE_TTL   = 3 * DAY_IN_SECONDS; // "Not in the directory" rarely changes; do not re-ask daily.
	const BREAKER_OPTION = 'upilot_api_breaker';
	const BREAKER_LIMIT  = 5;
	const BREAKER_COOLOFF = 900; // 15 minutes.

	/**
	 * Slugs per batch request.
	 *
	 * Measured against the live API: 90 slugs in one call succeeds, so 50 is a
	 * deliberately conservative ceiling. It keeps the request URL near 2.7 KB,
	 * comfortably under the ~4 KB that intermediate proxies are known to refuse.
	 */
	const BATCH_SIZE = 50;

	/**
	 * Fields we never read. Switching them off is the difference between roughly
	 * 29 KB and 2.6 KB per plugin.
	 *
	 * @var string[]
	 */
	private static $drop_fields = array(
		'sections',
		'description',
		'short_description',
		'screenshots',
		'banners',
		'icons',
		'ratings',
		'rating',
		'reviews',
		'downloadlink',
		'donate_link',
		'tags',
		'homepage',
		'versions',
		'added',
		'support_threads',
		'upgrade_notice',
		'preview_link',
		'repository_url',
	);

	/**
	 * Fields we do read.
	 *
	 * @var string[]
	 */
	private static $keep_fields = array(
		'contributors',
		'active_installs',
		'last_updated',
		'requires',
		'requires_php',
		'tested',
	);

	/**
	 * Optional HTTP budget: when set, at most this many live requests are made;
	 * further calls fall back to cache. Cache hits do not consume budget.
	 *
	 * @var int|null
	 */
	private $http_budget = null;

	/**
	 * Set (or clear) the HTTP budget for subsequent calls.
	 *
	 * @param int|null $budget Max live requests, or null for unlimited.
	 */
	public function set_http_budget( $budget ) {
		$this->http_budget = null === $budget ? null : max( 0, (int) $budget );
	}

	// ------------------------------------------------------------------ Batch.

	/**
	 * Fetch many plugins in as few requests as possible.
	 *
	 * This is the entry point every caller should use. Cached slugs cost nothing;
	 * the rest are fetched in batches of BATCH_SIZE.
	 *
	 * @param string[] $slugs      wp.org slugs.
	 * @param bool     $skip_cache Force a fresh fetch (the daily job does this;
	 *                             it IS the refresh mechanism).
	 * @return array<string,array> slug => {state, data}. state: ok|closed|not_found|api_error.
	 */
	public function get_plugins_info( array $slugs, $skip_cache = false ) {
		$out     = array();
		$to_ask  = array();

		foreach ( array_unique( $slugs ) as $raw ) {
			$slug = $this->normalise( $raw );
			if ( '' === $slug ) {
				continue;
			}
			if ( ! $skip_cache ) {
				$cached = UPilot_Store::get_transient( $this->cache_key( $slug ) );
				if ( is_array( $cached ) && isset( $cached['state'] ) ) {
					$out[ $slug ] = $cached;
					continue;
				}
			}
			$to_ask[] = $slug;
		}

		if ( empty( $to_ask ) ) {
			return $out;
		}

		// Backing off: serve whatever cache we have, and say "unknown" for the
		// rest. Never an error the user sees.
		if ( $this->should_back_off() ) {
			foreach ( $to_ask as $slug ) {
				$stale = UPilot_Store::get_transient( $this->cache_key( $slug ) );
				$out[ $slug ] = ( is_array( $stale ) && isset( $stale['state'] ) )
					? $stale
					: array(
						'state' => 'api_error',
						'data'  => array( 'reason' => 'backing_off' ),
					);
			}
			return $out;
		}

		foreach ( array_chunk( $to_ask, self::BATCH_SIZE ) as $chunk ) {
			if ( null !== $this->http_budget && $this->http_budget <= 0 ) {
				// Budget spent: fall back to cache (possibly stale) for the remainder.
				foreach ( $chunk as $slug ) {
					$stale        = UPilot_Store::get_transient( $this->cache_key( $slug ) );
					$out[ $slug ] = ( is_array( $stale ) && isset( $stale['state'] ) )
						? $stale
						: array(
							'state' => 'api_error',
							'data'  => array( 'reason' => 'budget_exhausted' ),
						);
				}
				continue;
			}

			$result = $this->request_json( $this->batch_url( $chunk ) );

			if ( 'api_error' === $result['state'] ) {
				foreach ( $chunk as $slug ) {
					$stale        = UPilot_Store::get_transient( $this->cache_key( $slug ) );
					$out[ $slug ] = ( is_array( $stale ) && isset( $stale['state'] ) )
						? $stale
						: $result;
				}
				continue;
			}

			$body = $result['data'];
			foreach ( $chunk as $slug ) {
				// A slug the API simply omitted is "not found", not an error.
				$entry  = isset( $body[ $slug ] ) && is_array( $body[ $slug ] )
					? $body[ $slug ]
					: array( 'error' => 'Plugin not found.' );
				$parsed = $this->parse_plugin_response( $entry, $slug );
				$this->cache( $slug, $parsed );
				$out[ $slug ] = $parsed;
			}
		}

		return $out;
	}

	/**
	 * Single-plugin convenience wrapper. Prefer get_plugins_info() — one call for
	 * forty plugins beats forty calls for one.
	 *
	 * @param string $slug       wp.org plugin slug.
	 * @param bool   $skip_cache Force a fresh fetch.
	 * @return array{state:string,data:array}
	 */
	public function get_plugin_info( $slug, $skip_cache = false ) {
		$slug = $this->normalise( $slug );
		$all  = $this->get_plugins_info( array( $slug ), $skip_cache );
		return isset( $all[ $slug ] )
			? $all[ $slug ]
			: array(
				'state' => 'api_error',
				'data'  => array( 'reason' => 'no_response' ),
			);
	}

	/**
	 * Build a batch request URL.
	 *
	 * @param string[] $slugs Slugs for this chunk.
	 * @return string
	 */
	private function batch_url( array $slugs ) {
		$args = array( 'action' => 'plugin_information' );

		foreach ( self::$drop_fields as $f ) {
			$args[ 'request[fields][' . $f . ']' ] = '0';
		}
		foreach ( self::$keep_fields as $f ) {
			$args[ 'request[fields][' . $f . ']' ] = '1';
		}

		$url = add_query_arg( $args, self::PLUGIN_INFO_URL );

		// add_query_arg cannot express repeated request[slugs][] keys, so append them.
		foreach ( $slugs as $slug ) {
			$url .= '&' . rawurlencode( 'request[slugs][]' ) . '=' . rawurlencode( $slug );
		}

		return $url;
	}

	/**
	 * Normalise a slug.
	 *
	 * @param string $slug Raw slug.
	 * @return string
	 */
	private function normalise( $slug ) {
		return sanitize_key( str_replace( '.', '-', (string) $slug ) );
	}

	/**
	 * Cache key for a slug.
	 *
	 * @param string $slug Slug.
	 * @return string
	 */
	private function cache_key( $slug ) {
		return 'upilot_pi_' . md5( $slug );
	}

	/**
	 * Store a parsed answer, with jitter so a fleet's caches do not all expire
	 * in the same minute.
	 *
	 * @param string $slug   Slug.
	 * @param array  $parsed Parsed answer.
	 */
	private function cache( $slug, $parsed ) {
		$ttl = ( 'not_found' === $parsed['state'] ) ? self::NEGATIVE_TTL : self::CACHE_TTL;
		UPilot_Store::set_transient( $this->cache_key( $slug ), $parsed, $ttl + wp_rand( 0, HOUR_IN_SECONDS ) );
	}

	// ------------------------------------------------------------------ Themes.

	/**
	 * Fetch theme info. The themes API has no documented multi-slug form, and a
	 * site has a handful of themes rather than dozens of plugins, so this stays
	 * one request per theme — cached for a day, network-wide.
	 *
	 * @param string $slug Theme slug.
	 * @return array{state:string,data:array}
	 */
	public function get_theme_info( $slug ) {
		$slug = $this->normalise( $slug );
		$key  = 'upilot_ti_' . md5( $slug );

		$cached = UPilot_Store::get_transient( $key );
		if ( is_array( $cached ) && isset( $cached['state'] ) ) {
			return $cached;
		}

		if ( $this->should_back_off() ) {
			return array(
				'state' => 'api_error',
				'data'  => array( 'reason' => 'backing_off' ),
			);
		}

		$url = add_query_arg(
			array(
				'action'                    => 'theme_information',
				'request[slug]'             => $slug,
				'request[fields][versions]' => '1',
			),
			self::THEME_INFO_URL
		);

		$result = $this->request_json( $url );
		if ( 'api_error' === $result['state'] ) {
			return $result;
		}

		$data = $result['data'];
		if ( isset( $data['error'] ) ) {
			$parsed = array(
				'state' => 'not_found',
				'data'  => array(),
			);
		} else {
			$parsed = array(
				'state' => 'ok',
				'data'  => array(
					'slug'         => isset( $data['slug'] ) ? $data['slug'] : $slug,
					'version'      => isset( $data['version'] ) ? $data['version'] : '',
					'requires'     => isset( $data['requires'] ) ? $data['requires'] : '',
					'requires_php' => isset( $data['requires_php'] ) ? $data['requires_php'] : '',
					'last_updated' => isset( $data['last_updated'] ) ? $data['last_updated'] : '',
					'author'       => isset( $data['author']['user_nicename'] )
						? $data['author']['user_nicename']
						: ( isset( $data['author'] ) && is_string( $data['author'] ) ? $data['author'] : '' ),
				),
			);
		}

		$ttl = ( 'not_found' === $parsed['state'] ) ? self::NEGATIVE_TTL : self::CACHE_TTL;
		UPilot_Store::set_transient( $key, $parsed, $ttl + wp_rand( 0, HOUR_IN_SECONDS ) );
		return $parsed;
	}

	// ------------------------------------------------------------------ Parsing.

	/**
	 * Normalise one plugin entry into our state machine shape.
	 *
	 * @param array  $data Decoded entry for a single plugin.
	 * @param string $slug The slug we asked for.
	 * @return array{state:string,data:array}
	 */
	private function parse_plugin_response( $data, $slug = '' ) {
		// Closed plugin: {"error":"closed","closed":true,"closed_date":...,"reason_text":"Security Issue"}.
		if ( isset( $data['error'] ) ) {
			if ( ( isset( $data['error'] ) && 'closed' === $data['error'] ) || ! empty( $data['closed'] ) ) {
				$reason = '';
				if ( isset( $data['reason_text'] ) && is_string( $data['reason_text'] ) ) {
					$reason = $data['reason_text'];
				} elseif ( isset( $data['reason'] ) && is_string( $data['reason'] ) ) {
					$reason = $data['reason'];
				} elseif ( isset( $data['description'] ) && preg_match( '/Reason:\s*(.+?)\.?\s*$/i', wp_strip_all_tags( $data['description'] ), $m ) ) {
					$reason = $m[1];
				}
				return array(
					'state' => 'closed',
					'data'  => array(
						'slug'        => isset( $data['slug'] ) ? $data['slug'] : $slug,
						'name'        => isset( $data['name'] ) ? $data['name'] : '',
						'closed_date' => isset( $data['closed_date'] ) ? $data['closed_date'] : '',
						'reason'      => $reason,
					),
				);
			}
			return array(
				'state' => 'not_found',
				'data'  => array(),
			);
		}

		// Preserve absence vs empty: a missing contributors field (API degradation)
		// must not diff as "all contributors removed" downstream.
		$contributors = null;
		if ( isset( $data['contributors'] ) && is_array( $data['contributors'] ) ) {
			$contributors = array_keys( $data['contributors'] );
			sort( $contributors );
		}

		$author = '';
		if ( isset( $data['author'] ) && is_string( $data['author'] ) ) {
			$author = wp_strip_all_tags( $data['author'] );
		}

		return array(
			'state' => 'ok',
			'data'  => array(
				'slug'            => isset( $data['slug'] ) ? $data['slug'] : $slug,
				'name'            => isset( $data['name'] ) ? wp_strip_all_tags( $data['name'] ) : '',
				'version'         => isset( $data['version'] ) ? $data['version'] : '',
				'author'          => $author,
				'contributors'    => $contributors,
				'requires'        => isset( $data['requires'] ) ? $data['requires'] : '',
				'requires_php'    => isset( $data['requires_php'] ) ? $data['requires_php'] : '',
				'tested'          => isset( $data['tested'] ) ? $data['tested'] : '',
				'last_updated'    => isset( $data['last_updated'] ) ? $data['last_updated'] : '',
				'active_installs' => isset( $data['active_installs'] ) ? (int) $data['active_installs'] : 0,
			),
		);
	}

	// ------------------------------------------------------------------ Transport.

	/**
	 * Perform a GET request expecting JSON.
	 *
	 * @param string $url Request URL.
	 * @return array{state:string,data:array}
	 */
	private function request_json( $url ) {
		if ( null !== $this->http_budget ) {
			if ( $this->http_budget <= 0 ) {
				return array(
					'state' => 'api_error',
					'data'  => array( 'reason' => 'budget_exhausted' ),
				);
			}
			$this->http_budget--;
		}

		// The user-agent carries the plugin version and NOTHING ELSE. It must not
		// carry the site URL: readme.txt promises that only public plugin slugs
		// leave the site, and that promise has to survive contact with the code.
		$response = wp_remote_get(
			$url,
			array(
				'timeout'    => 20,
				'user-agent' => 'UpgradePilot/' . UPILOT_VERSION,
			)
		);

		if ( is_wp_error( $response ) ) {
			$this->breaker_record_failure();
			return array(
				'state' => 'api_error',
				'data'  => array( 'reason' => $response->get_error_code() ),
			);
		}

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

		// Rate limited or server-side trouble: back off, and honour Retry-After.
		if ( 429 === $code || $code >= 500 ) {
			$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
			$this->start_backoff( $retry_after );
			$this->breaker_record_failure();
			return array(
				'state' => 'api_error',
				'data'  => array( 'reason' => 'http_' . $code ),
			);
		}

		$body = wp_remote_retrieve_body( $response );
		$data = json_decode( $body, true );

		// wp.org answers 404 with a JSON body for both closed and missing plugins —
		// that is a valid answer, not an error.
		if ( ! is_array( $data ) ) {
			if ( 0 === $code ) {
				$this->breaker_record_failure();
			}
			return array(
				'state' => 'api_error',
				'data'  => array( 'reason' => 'http_' . $code ),
			);
		}

		$this->breaker_record_success();
		return array(
			'state' => 'transport_ok',
			'data'  => $data,
		);
	}

	// ------------------------------------------------------------------ Backoff.

	/**
	 * Should we refrain from calling the API right now?
	 *
	 * @return bool
	 */
	private function should_back_off() {
		$breaker = UPilot_Store::get_option( self::BREAKER_OPTION, array() );
		if ( ! is_array( $breaker ) ) {
			return false;
		}

		// Explicit backoff window (429 / 5xx, possibly Retry-After driven).
		if ( ! empty( $breaker['retry_at'] ) && time() < (int) $breaker['retry_at'] ) {
			return true;
		}

		return $this->breaker_open();
	}

	/**
	 * Open a backoff window. Honours Retry-After when supplied; otherwise backs
	 * off exponentially from 1 minute, capped at 6 hours.
	 *
	 * @param int $retry_after Seconds from a Retry-After header (0 if absent).
	 */
	private function start_backoff( $retry_after = 0 ) {
		$breaker = UPilot_Store::get_option( self::BREAKER_OPTION, array() );
		if ( ! is_array( $breaker ) ) {
			$breaker = array();
		}

		$step = isset( $breaker['backoff_step'] ) ? (int) $breaker['backoff_step'] : 0;
		$step = min( $step + 1, 9 );

		$wait = $retry_after > 0
			? min( $retry_after, 6 * HOUR_IN_SECONDS )
			: min( MINUTE_IN_SECONDS * pow( 2, $step - 1 ), 6 * HOUR_IN_SECONDS );

		$breaker['backoff_step'] = $step;
		$breaker['retry_at']     = time() + (int) $wait;

		UPilot_Store::update_option( self::BREAKER_OPTION, $breaker );
	}

	/**
	 * Whether the circuit breaker is open (API considered down).
	 *
	 * @return bool
	 */
	private function breaker_open() {
		$breaker = UPilot_Store::get_option( self::BREAKER_OPTION, array() );
		if ( ! is_array( $breaker ) || empty( $breaker['failures'] ) || $breaker['failures'] < self::BREAKER_LIMIT ) {
			return false;
		}
		if ( empty( $breaker['opened_at'] ) ) {
			return false;
		}
		if ( ( time() - (int) $breaker['opened_at'] ) < self::BREAKER_COOLOFF ) {
			return true;
		}
		// Cool-off elapsed: move to half-open so a fresh failure streak can re-open.
		$breaker['failures']  = self::BREAKER_LIMIT - 1;
		$breaker['opened_at'] = 0;
		UPilot_Store::update_option( self::BREAKER_OPTION, $breaker );
		return false;
	}

	/**
	 * Record an API transport failure.
	 */
	private function breaker_record_failure() {
		$breaker = UPilot_Store::get_option( self::BREAKER_OPTION, array() );
		if ( ! is_array( $breaker ) ) {
			$breaker = array();
		}
		$breaker['failures'] = empty( $breaker['failures'] ) ? 1 : (int) $breaker['failures'] + 1;
		if ( $breaker['failures'] >= self::BREAKER_LIMIT && empty( $breaker['opened_at'] ) ) {
			$breaker['opened_at'] = time();
		}
		UPilot_Store::update_option( self::BREAKER_OPTION, $breaker );
	}

	/**
	 * Record an API success: clear failures and the backoff ladder.
	 */
	private function breaker_record_success() {
		if ( UPilot_Store::get_option( self::BREAKER_OPTION ) ) {
			UPilot_Store::delete_option( self::BREAKER_OPTION );
		}
	}
}
