<?php
/**
 * Postomatic utility methods for handling options and security.
 *
 * @package Postomatic
 */

namespace Postomatic;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Class Utils.
 *
 * Contains utility methods for the Postomatic plugin.
 */
class Utils {

	/**
	 * Generates a secure cryptographic key.
	 *
	 * @param  int $length Length of the key to generate (default: 64).
	 * @return string The generated secure key.
	 */
	public static function generate_secure_key( $length = 64 ) {
		// Generate secure random bytes.
		$random_bytes = random_bytes( $length );

		// Convert bytes to a hexadecimal string.
		$secure_key = bin2hex( $random_bytes );

		// Return the secure key (it will be twice as long as $length).
		return substr( $secure_key, 0, $length );
	}

	/**
	 * Updates or adds the Postomatic code option.
	 *
	 * @param  string $value Option value to set.
	 * @return bool   True if option was updated, false otherwise.
	 */
	public static function update_or_add_code_option( $value ) {
		$option_name = Constants::OPTION_CODE;

		if ( false !== get_option( $option_name ) ) {
			return update_option( $option_name, $value );
		}

		return add_option( $option_name, $value );
	}

	/**
	 * Gets the Postomatic code option.
	 *
	 * @return mixed The option value or false if not exists.
	 */
	public static function get_code_option() {
		return get_option( Constants::OPTION_CODE );
	}

	/**
	 * Updates or adds the WordPress API secure key option.
	 *
	 * @param  string $value Option value to set.
	 * @return bool   True if option was updated, false otherwise.
	 */
	public static function update_or_add_wp_api_secure_key( $value ) {
		$option_name = Constants::OPTION_API_WP_SECURE_KEY;

		if ( false !== get_option( $option_name ) ) {
			return update_option( $option_name, $value );
		}

		return add_option( $option_name, $value );
	}

	/**
	 * Gets the WordPress API secure key option.
	 *
	 * @return mixed The option value or false if not exists.
	 */
	public static function get_wp_api_secure_key() {
		return get_option( Constants::OPTION_API_WP_SECURE_KEY );
	}

	/**
	 * Updates or adds the API service key option.
	 *
	 * @param  string $value Option value to set.
	 * @return bool   True if option was updated, false otherwise.
	 */
	public static function update_or_add_api_service_key( $value ) {
		$option_name = Constants::OPTION_API_SERVICE_KEY;

		if ( false !== get_option( $option_name ) ) {
			return update_option( $option_name, $value );
		}

		return add_option( $option_name, $value );
	}

	/**
	 * Gets the API service key option.
	 *
	 * @return mixed The option value or false if not exists.
	 */
	public static function get_api_service_key() {
		return get_option( Constants::OPTION_API_SERVICE_KEY );
	}

	/**
	 * Updates or adds the is_validating option.
	 *
	 * @param  string $value Option value to set.
	 * @return bool   True if option was updated, false otherwise.
	 */
	public static function update_or_add_is_validating( $value ) {
		$option_name = Constants::OPTION_API_IS_VALIDATING;

		if ( false !== get_option( $option_name ) ) {
			return update_option( $option_name, $value );
		}

		return add_option( $option_name, $value );
	}

	/**
	 * Gets the is_validating option.
	 *
	 * @return mixed The option value or false if not exists.
	 */
	public static function get_is_validating() {
		return get_option( Constants::OPTION_API_IS_VALIDATING );
	}

	/**
	 * How long a staged activation stays valid, in seconds.
	 *
	 * Matches the service's own 30 minute staging window.
	 */
	public const PENDING_ACTIVATION_TTL = 30 * MINUTE_IN_SECONDS;

	/**
	 * Stage the credentials for an activation attempt.
	 *
	 * The service no longer applies a re-activation on receipt; it parks the new
	 * credentials and only promotes them once it has completed the inbound /cr
	 * handshake with this site. The plugin mirrors that: until the handshake
	 * lands, the previously live code and WP API secret are the ones still
	 * accepted, so a failed or hijacked activation attempt cannot knock a working
	 * site offline.
	 *
	 * @param  string $code          Activation code sent to the service.
	 * @param  string $wp_api_secret WP API secret sent to the service.
	 * @return void
	 */
	public static function stage_pending_activation( $code, $wp_api_secret ) {
		update_option(
			Constants::OPTION_PENDING_ACTIVATION,
			array(
				'code'          => (string) $code,
				'wp_api_secret' => (string) $wp_api_secret,
				'created_at'    => time(),
			),
			false
		);
	}

	/**
	 * Get the staged activation, if there is a usable one.
	 *
	 * @return array{code:string, wp_api_secret:string, created_at:int}|null Null when absent or expired.
	 */
	public static function get_pending_activation() {
		$pending = get_option( Constants::OPTION_PENDING_ACTIVATION );

		if ( ! is_array( $pending ) || empty( $pending['code'] ) || empty( $pending['created_at'] ) ) {
			return null;
		}

		// Expired staging must never authorise a handshake.
		if ( ( time() - (int) $pending['created_at'] ) > self::PENDING_ACTIVATION_TTL ) {
			self::clear_pending_activation();

			return null;
		}

		return array(
			'code'          => (string) $pending['code'],
			'wp_api_secret' => (string) ( $pending['wp_api_secret'] ?? '' ),
			'created_at'    => (int) $pending['created_at'],
		);
	}

	/**
	 * Discard the staged activation.
	 *
	 * Called as soon as a handshake succeeds, which is what makes an activation
	 * code single use.
	 *
	 * @return void
	 */
	public static function clear_pending_activation() {
		delete_option( Constants::OPTION_PENDING_ACTIVATION );
	}

	/**
	 * Promote a staged activation to live.
	 *
	 * @param  array  $pending Staged activation from get_pending_activation().
	 * @param  string $api_key API service key handed over by the service.
	 * @return void
	 */
	public static function promote_pending_activation( array $pending, $api_key ) {
		self::update_or_add_code_option( $pending['code'] );

		if ( ! empty( $pending['wp_api_secret'] ) ) {
			self::update_or_add_wp_api_secure_key( $pending['wp_api_secret'] );
		}

		self::update_or_add_api_service_key( (string) $api_key );
		self::update_or_add_is_validating( false );
		self::clear_pending_activation();
		self::flush_api_cache();
	}

	/**
	 * Seconds to wait before retrying a throttled request.
	 *
	 * The API is rate limited (5/min per IP on activation, 120/min per
	 * credential elsewhere). A 429 is a "come back later", not a bad credential,
	 * and must never be retried in a tight loop: three activation attempts per
	 * minute for one site is the ceiling, and burning them locks the user out of
	 * activating for the rest of the minute.
	 *
	 * @param  array|\WP_Error $response Response from a wp_remote_* call.
	 * @param  int             $attempt  Zero-based retry attempt, used for backoff.
	 * @return int Seconds to wait.
	 */
	public static function get_retry_after_seconds( $response, $attempt = 0 ) {
		if ( ! is_wp_error( $response ) ) {
			$header = wp_remote_retrieve_header( $response, 'retry-after' );

			if ( is_numeric( $header ) ) {
				return max( 1, min( 120, (int) $header ) );
			}

			// Retry-After may also be an HTTP date.
			if ( ! empty( $header ) ) {
				$timestamp = strtotime( (string) $header );

				if ( $timestamp ) {
					return max( 1, min( 120, $timestamp - time() ) );
				}
			}
		}

		// Exponential backoff with jitter when the header is absent.
		$base = min( 60, 2 ** max( 0, (int) $attempt ) );

		return max( 1, $base + wp_rand( 0, 3 ) );
	}

	/**
	 * Check whether a response was throttled.
	 *
	 * @param  array|\WP_Error $response Response from a wp_remote_* call.
	 * @return bool
	 */
	public static function is_rate_limited( $response ) {
		return ! is_wp_error( $response ) && 429 === (int) wp_remote_retrieve_response_code( $response );
	}

	/**
	 * The site's canonical URL, without a trailing slash.
	 *
	 * The service normalises the URL it is given by stripping the trailing
	 * slash, so sending an inconsistent form (home_url() in one call, site_url()
	 * with a slash in another) is how one install ends up as two rows. Every
	 * call that identifies this site to the service goes through here.
	 *
	 * @return string
	 */
	public static function get_canonical_site_url() {
		return untrailingslashit( home_url() );
	}

	/**
	 * The public REST base the service should call back on, or '' when unusable.
	 *
	 * The service requires api_endpoint to be on the same host as url and
	 * rejects the activation with a 422 otherwise, so a mismatch (headless or
	 * reverse-proxied installs) is reported as "omit it" rather than sent and
	 * refused. Omitting it makes the service fall back to {url}/wp-json.
	 *
	 * @return string
	 */
	public static function get_callback_endpoint() {
		$endpoint = untrailingslashit( get_rest_url( null, Constants::API_WP_ENDPOINT_BASE ) );

		$site_host     = wp_parse_url( self::get_canonical_site_url(), PHP_URL_HOST );
		$endpoint_host = wp_parse_url( $endpoint, PHP_URL_HOST );

		if ( empty( $endpoint_host ) || strtolower( (string) $site_host ) !== strtolower( (string) $endpoint_host ) ) {
			return '';
		}

		return $endpoint;
	}

	/**
	 * Checks if a provided token matches the stored secure key.
	 *
	 * @param  string $token The token to validate.
	 * @return bool   True if token is valid, false otherwise.
	 */
	public static function check_authorization( $token ) {
		return self::get_wp_api_secure_key() === $token;
	}

	/*
	 * One-time code helpers.
	 *
	 * These used to hold a second, subtly different implementation of the same
	 * storage that Codemanager owns: it wrote a bare string and so replaced any
	 * existing code, while Codemanager now keeps a map of code => timestamp.
	 * Two writers with different formats against one option is how codes ended
	 * up being rejected, so these now delegate rather than reimplement.
	 */

	/**
	 * Saves a one-time code.
	 *
	 * @param  string $code The code to save.
	 * @return void
	 */
	public static function save_one_time_code( $code ) {
		( new Codemanager() )->add_codes( $code );
	}

	/**
	 * Checks if a one-time code exists and is still valid.
	 *
	 * @param  string $code The code to check.
	 * @return bool   True if code exists, false otherwise.
	 */
	public static function check_one_time_code_exists( $code ) {
		return ( new Codemanager() )->code_exists( $code );
	}

	/**
	 * Removes a one-time code.
	 *
	 * @param  string $code The code to remove.
	 * @return void
	 */
	public static function get_and_remove_one_time_code( $code ) {
		( new Codemanager() )->delete_code( $code );
	}

	/**
	 * Generates a unique, readable identifier for autopilot tasks.
	 *
	 * @return string A unique identifier that is a mix of characters and numbers.
	 */
	public static function generate_autopilot_identifier() {
		// Define character sets for the identifier.
		$chars   = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // Uppercase letters (excluding easily confused I and O).
		$numbers = '23456789'; // Numbers (excluding easily confused 0 and 1).

		// Generate a timestamp-based prefix to ensure uniqueness.
		$prefix = strtoupper( substr( uniqid(), -4 ) );

		// Generate 3 random characters.
		$random_chars = '';
		for ( $i = 0; $i < 3; $i++ ) {
			$random_chars .= $chars[ wp_rand( 0, strlen( $chars ) - 1 ) ];
		}

		// Generate 3 random numbers.
		$random_numbers = '';
		for ( $i = 0; $i < 3; $i++ ) {
			$random_numbers .= $numbers[ wp_rand( 0, strlen( $numbers ) - 1 ) ];
		}

		// Combine all parts with hyphens for readability.
		$identifier = $prefix . '-' . $random_chars . '-' . $random_numbers;

		return $identifier;
	}

	/**
	 * Get the users who can be selected as a post author.
	 *
	 * Returns users able to edit posts, each as an object exposing `ID` and
	 * `display_name`.
	 *
	 * The `who => 'authors'` argument this previously relied on was deprecated in
	 * WordPress 5.9 in favour of `capability`. On sites where a role grants
	 * `edit_posts` through a custom capability map, the deprecated argument could
	 * return an empty list, which left the author dropdown empty and allowed a
	 * task to be saved with no author. `capability` is used where available, with
	 * `who` kept as a fallback for WordPress 5.8.
	 *
	 * @return array List of user objects with ID and display_name.
	 */
	public static function get_post_authors() {
		$args = array(
			'fields'  => array( 'ID', 'display_name' ),
			'orderby' => 'display_name',
			'order'   => 'ASC',
		);

		if ( version_compare( get_bloginfo( 'version' ), '5.9', '>=' ) ) {
			$args['capability'] = array( 'edit_posts' );
		} else {
			$args['who'] = 'authors';
		}

		$authors = get_users( $args );

		/*
		 * Never hand back an empty list: an empty author dropdown is how posts
		 * ended up with no author. Fall back to all users, then to the current one.
		 */
		if ( empty( $authors ) ) {
			$authors = get_users(
				array(
					'fields'  => array( 'ID', 'display_name' ),
					'orderby' => 'display_name',
					'order'   => 'ASC',
				)
			);
		}

		if ( empty( $authors ) ) {
			$current = wp_get_current_user();

			if ( $current && $current->ID ) {
				$authors = array(
					(object) array(
						'ID'           => $current->ID,
						'display_name' => $current->display_name,
					),
				);
			}
		}

		return $authors;
	}

	/**
	 * Cache keys managed by remember_api_data() / flush_api_cache().
	 *
	 * Only site-wide, user-independent responses belong here. Per-account data
	 * such as the credit balance or the active plan is deliberately absent: it
	 * changes the moment a post is generated or a plan is bought, and serving a
	 * stale copy of it would be worse than the round trip it saves.
	 */
	private const CACHEABLE_API_KEYS = array( 'pricing_plans', 'pricing_credits' );

	/**
	 * Return a cached API response, fetching and caching it when absent.
	 *
	 * @param  string   $cache_key     One of self::CACHEABLE_API_KEYS.
	 * @param  int      $ttl           Cache lifetime in seconds.
	 * @param  callable $callback      Fetches the data when the cache misses.
	 * @param  bool     $force_refresh Skip the cache and refetch.
	 * @return mixed The cached or freshly fetched data.
	 */
	public static function remember_api_data( string $cache_key, int $ttl, callable $callback, bool $force_refresh = false ) {
		$transient_key = Constants::PREFIX . 'cache_' . $cache_key;

		if ( ! $force_refresh ) {
			$cached = get_transient( $transient_key );

			if ( false !== $cached ) {
				return $cached;
			}
		}

		$data = $callback();

		/*
		 * An empty result is what these handlers return when the API call fails,
		 * so caching it would keep the page blank for the whole TTL instead of
		 * retrying on the next load.
		 */
		if ( ! empty( $data ) ) {
			set_transient( $transient_key, $data, $ttl );
		}

		return $data;
	}

	/**
	 * Drop every cached API response.
	 *
	 * Called after the API key is rotated, since anything cached was fetched
	 * with the previous credentials.
	 *
	 * @return void
	 */
	public static function flush_api_cache() {
		foreach ( self::CACHEABLE_API_KEYS as $cache_key ) {
			delete_transient( Constants::PREFIX . 'cache_' . $cache_key );
		}

		// Credit data is cached separately for the low-credit admin notice.
		delete_transient( 'postomatic_credit_notice_data' );
	}

	/**
	 * Get the ordered list of subscription plan slugs, lowest tier first.
	 *
	 * @return array<int, string> Plan slugs in ascending tier order.
	 */
	public static function get_plan_tiers(): array {
		/**
		 * Filters the ordered list of plan slugs used for upgrade decisions.
		 *
		 * @param array<int, string> $tiers Plan slugs, lowest tier first.
		 */
		$tiers = apply_filters( 'postomatic_plan_tiers', Constants::PLAN_TIERS );

		return array_values( array_filter( array_map( 'strval', (array) $tiers ) ) );
	}

	/**
	 * Get the tier rank of a plan slug.
	 *
	 * Unknown or empty slugs (including the API's 'free') rank below every real
	 * plan so that they always see an upgrade prompt.
	 *
	 * @param string $plan_slug Plan slug reported by the API.
	 *
	 * @return int Zero-based rank, or -1 when the plan is not a known tier.
	 */
	public static function get_plan_rank( $plan_slug ): int {
		$plan_slug = is_string( $plan_slug ) ? strtolower( trim( $plan_slug ) ) : '';

		if ( '' === $plan_slug ) {
			return -1;
		}

		$index = array_search( $plan_slug, self::get_plan_tiers(), true );

		return false === $index ? -1 : (int) $index;
	}

	/**
	 * Check whether a plan slug is the highest available tier.
	 *
	 * Used to suppress "Upgrade Plan" calls to action for users who have already
	 * bought the top plan and have nothing left to upgrade to.
	 *
	 * @param string $plan_slug Plan slug reported by the API.
	 *
	 * @return bool True when the plan is the top tier.
	 */
	public static function is_highest_plan( $plan_slug ): bool {
		$tiers = self::get_plan_tiers();

		if ( empty( $tiers ) ) {
			return false;
		}

		return self::get_plan_rank( $plan_slug ) === ( count( $tiers ) - 1 );
	}

	/**
	 * Get the phrases that identify an API notice as an upgrade pitch.
	 *
	 * Passed to the admin templates so the browser can drop the remotely
	 * authored "important notice" when it only sells a higher plan and the user
	 * already owns the top one.
	 *
	 * @return array<int, string> Lowercase phrases.
	 */
	public static function get_upgrade_notice_keywords(): array {
		/**
		 * Filters the phrases that mark an API notice as an upgrade pitch.
		 *
		 * @param array<int, string> $keywords Lowercase phrases.
		 */
		$keywords = apply_filters( 'postomatic_upgrade_notice_keywords', Constants::UPGRADE_NOTICE_KEYWORDS );

		$keywords = array_map(
			static function ( $keyword ) {
				return strtolower( trim( (string) $keyword ) );
			},
			(array) $keywords
		);

		return array_values( array_filter( $keywords ) );
	}
}
