<?php
/**
 * Plugin activation functionality.
 *
 * @package Postomatic
 * @subpackage Core
 */

namespace Postomatic\Core;

// Class for handling plugin activation.
use Postomatic\Utils;

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

/**
 * Activates the plugin.
 *
 * Creates necessary database tables and notifies the API.
 *
 * @return void
 */
class Activator {

	/**
	 * Activates the plugin.
	 *
	 * Creates necessary database tables and notifies the API.
	 *
	 * @return bool
	 */
	public static function activate() {
		// Store activation info for admin notice.
		update_option( 'postomatic_activation_status', 'Postomatic plugin activated successfully' );

		// Create database table.
		self::create_table();

		// Send data to Laravel API.
		self::send_post_to_laravel_active_api();

		// Preventing multiple redirects after activation.
		set_transient( 'postomatic_do_activation_redirect', true, 30 );

		// Register admin notice hook.
		add_action( 'admin_notices', array( __CLASS__, 'activation_admin_notice' ) );

		return true;
	}


	/**
	 * Display admin notice after activation.
	 *
	 * @return void
	 */
	public static function activation_admin_notice() {
		$message = get_option( 'postomatic_activation_status' );
		if ( $message ) {
			echo '<div class="notice notice-success is-dismissible"><p>' . esc_html( $message ) . '</p></div>';
			delete_option( 'postomatic_activation_status' );
		}

		if ( get_option( 'postomatic_activation_error' ) ) {
			$error_messages = get_option( 'postomatic_activation_error' );
			foreach ( $error_messages as $error ) {
				echo '<div class="notice notice-error is-dismissible"><p>' . esc_html( $error ) . '</p></div>';
			}
			delete_option( 'postomatic_activation_error' );
		}
	}

	/**
	 * Creates the database table needed by the plugin.
	 *
	 * @return void
	 */
	private static function create_table() {
		global $wpdb;

		$table_name = $wpdb->prefix . \Postomatic\Constants::TABLE_TITLES;

		$sql = "CREATE TABLE IF NOT EXISTS $table_name (
            id mediumint(9) NOT NULL AUTO_INCREMENT,
            title varchar(255) NOT NULL,
            post_date datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
            PRIMARY KEY (id)
        );";

		include_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );
	}

	/**
	 * Name of the transient used to serialise concurrent key refreshes.
	 */
	private const REFRESH_LOCK = 'postomatic_key_refresh_lock';

	/**
	 * Name of the transient marking a very recent successful refresh.
	 */
	private const REFRESH_COOLDOWN = 'postomatic_key_refreshed_recently';

	/**
	 * Name of the transient recording that activation is currently throttled.
	 */
	private const THROTTLED_UNTIL = 'postomatic_activation_throttled_until';

	/**
	 * Refresh the API key after a 401, waiting only as long as the handshake needs.
	 *
	 * Every caller that hit a 401 used to run `Activator::activate()` followed by
	 * an unconditional `sleep( 10 )`, then retry up to twice. That had three
	 * problems this method addresses:
	 *
	 * 1. The ten second sleep was paid in full on every attempt even though the
	 *    service's callback normally lands in well under a second, so a single
	 *    401 cost twenty to thirty seconds of a blocked admin-ajax request. The
	 *    wait now ends as soon as the new key is actually stored.
	 * 2. `activate()` also re-runs dbDelta() and re-arms the activation redirect
	 *    transient, neither of which has anything to do with refreshing a key.
	 * 3. Nothing stopped two requests refreshing at the same time. Each refresh
	 *    rotates both the refresh token and the WP API secret, so the second
	 *    handshake invalidated the first, and the site could end up holding a
	 *    key the service had already replaced — which is the state that leaves
	 *    the UI bouncing through "Renewing your API key" indefinitely. A lock
	 *    now makes late arrivals wait for the in-flight refresh instead.
	 *
	 * @return bool True when a new key is in place, false otherwise.
	 */
	public static function refresh_api_key(): bool {
		// A refresh that has only just succeeded is still good; a burst of 401s
		// from parallel requests should not each trigger another rotation.
		if ( false !== get_transient( self::REFRESH_COOLDOWN ) ) {
			return true;
		}

		// Activation is rate limited to 3/min per site URL. Once throttled, stop
		// asking until the window has passed rather than spending the remaining
		// attempts on requests that will be refused anyway.
		if ( false !== get_transient( self::THROTTLED_UNTIL ) ) {
			return false;
		}

		$previous_key = Utils::get_api_service_key();

		if ( false !== get_transient( self::REFRESH_LOCK ) ) {
			// Another request owns the handshake; wait for its result.
			return self::wait_for_new_api_key( $previous_key );
		}

		set_transient( self::REFRESH_LOCK, time(), 2 * MINUTE_IN_SECONDS );

		try {
			if ( false === self::send_post_to_laravel_active_api( 15 ) ) {
				return false;
			}

			$refreshed = self::wait_for_new_api_key( $previous_key );

			if ( $refreshed ) {
				set_transient( self::REFRESH_COOLDOWN, time(), 10 );

				// Cached API responses were fetched with the old key and may be
				// stale now.
				Utils::flush_api_cache();
			}

			return $refreshed;
		} finally {
			delete_transient( self::REFRESH_LOCK );
		}
	}

	/**
	 * Wait for the service to call back and store a new API service key.
	 *
	 * The key is written by a separate request (the REST `/cr` callback), so the
	 * option has to be re-read from the database rather than from this process's
	 * options cache.
	 *
	 * @param  string|false $previous_key Key held before the refresh started.
	 * @param  int          $timeout_ms   How long to wait in total, in milliseconds.
	 * @param  int          $interval_ms  Poll interval, in milliseconds.
	 * @return bool True once a different, non-empty key is stored.
	 */
	private static function wait_for_new_api_key( $previous_key, $timeout_ms = 12000, $interval_ms = 400 ): bool {
		$waited = 0;

		while ( $waited < $timeout_ms ) {
			usleep( $interval_ms * 1000 );
			$waited += $interval_ms;

			$current_key = self::get_fresh_api_service_key();

			if ( ! empty( $current_key ) && $current_key !== $previous_key ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Read the API service key straight from the database.
	 *
	 * @return mixed Current key value.
	 */
	private static function get_fresh_api_service_key() {
		// The key is stored as an autoloaded option, so both the individual
		// entry and the alloptions bucket have to be dropped for get_option()
		// to go back to the database.
		wp_cache_delete( \Postomatic\Constants::OPTION_API_SERVICE_KEY, 'options' );
		wp_cache_delete( 'alloptions', 'options' );

		return Utils::get_api_service_key();
	}

	/**
	 * Sends activation data to the Laravel API endpoint.
	 *
	 * @param  int $timeout Request timeout in seconds.
	 * @return bool|string Response body on success, false on failure.
	 */
	private static function send_post_to_laravel_active_api( $timeout = 45 ) {

		$api_base_url = \Postomatic\Constants::API_BASE_URL ?? '';
		if ( empty( $api_base_url ) ) {
			// Store error for admin notice.
			self::add_activation_error( 'API Base URL is not defined' );

			return false;
		}

		$api_url = $api_base_url . '/api/services/postomatic/active';
		$wp_url  = Utils::get_canonical_site_url();

		/*
		 * Fresh, cryptographically random credentials for every attempt, never
		 * derived from the URL, an install timestamp or uniqid(). The service
		 * accepts `code` as an API credential, so a guessable value would be a
		 * way into the account.
		 */
		$code          = self::generate_secure_key( 64 );
		$wp_api_secret = self::generate_secure_key( 64 );

		/*
		 * Staged, not applied. The service does not apply a re-activation until
		 * it has completed the /cr handshake with this site, so writing these
		 * straight to the live options (as this used to do) meant a re-activation
		 * immediately invalidated a perfectly good credential — and left the site
		 * unable to talk to the API at all if the handshake then failed.
		 * Code_Endpoint promotes them once the handshake proves ownership.
		 */
		Utils::stage_pending_activation( $code, $wp_api_secret );

		/*
		 * Strictly the fields the service accepts. It validates against an
		 * allow-list and silently drops anything else; the privileged fields it
		 * used to mass-assign (credit, plan_id, api_secret, is_active, …) were
		 * the account-takeover vector and are deliberately not sent.
		 */
		$data = array(
			'url'                 => $wp_url,
			'code'                => $code,
			'wp_api_secret'       => $wp_api_secret,
			'blog_local_timezone' => substr( (string) wp_timezone_string(), 0, 64 ),
		);

		/*
		 * api_endpoint has to be on the same host as url or the service answers
		 * 422. get_callback_endpoint() returns '' when it is not, in which case
		 * the field is omitted and the service falls back to {url}/wp-json.
		 */
		$callback_endpoint = Utils::get_callback_endpoint();

		if ( '' !== $callback_endpoint ) {
			$data['api_endpoint'] = $callback_endpoint;
		} else {
			self::add_activation_error(
				'The site REST URL is on a different host than the site URL, so the callback endpoint was omitted. '
				. 'If this site is headless or behind a proxy, contact support.'
			);
		}

		$args = array(
			'body'        => wp_json_encode( $data ),
			'headers'     => array(
				'Content-Type' => 'application/json',
				'Accept'       => 'application/json',
			),
			'method'      => 'POST',
			'httpversion' => '1.1',
			'timeout'     => (int) $timeout,
		);

		$response = wp_remote_post( $api_url, $args );

		if ( is_wp_error( $response ) ) {
			// Store error for admin notice.
			self::add_activation_error( 'API Request Failed: ' . $response->get_error_message() );

			return false;
		}

		$response_code = wp_remote_retrieve_response_code( $response );
		$response_body = wp_remote_retrieve_body( $response );

		/*
		 * Activation is throttled to 5/min per IP and 3/min per site URL. Record
		 * when we may try again and give up for now; retrying immediately would
		 * spend the site's remaining attempts and lock activation for the rest of
		 * the minute.
		 */
		if ( 429 === (int) $response_code ) {
			$retry_after = Utils::get_retry_after_seconds( $response );

			set_transient( self::THROTTLED_UNTIL, time() + $retry_after, $retry_after );
			self::add_activation_error(
				sprintf(
					/* translators: %d: seconds to wait. */
					esc_html__( 'Postomatic is temporarily rate limited. Please try again in %d seconds.', 'postomatic' ),
					$retry_after
				)
			);

			return false;
		}

		if ( 422 === (int) $response_code ) {
			$decoded = json_decode( $response_body, true );
			$message = is_array( $decoded ) && ! empty( $decoded['message'] )
				? $decoded['message']
				: 'The service rejected the activation details.';

			self::add_activation_error( 'Activation rejected: ' . $message );

			return false;
		}

		if ( 200 !== (int) $response_code ) {
			// Store errors for admin notice.
			self::add_activation_error( "API Request Failed with code: $response_code" );
			self::add_activation_error( "Response Body: $response_body" );

			return false;
		}

		/*
		 * A 200 only means the request was accepted. On a re-activation the new
		 * credential is staged server-side and does not become live until the
		 * /cr handshake completes, which is why callers wait for the callback
		 * rather than treating this as "connected".
		 */
		return $response_body;
	}

	/**
	 * Generates a secure random key.
	 *
	 * @param  int $length Length of the key in characters (hex, so always even).
	 * @return string Generated secure key.
	 */
	private static function generate_secure_key( $length ) {
		$length = max( 32, (int) $length );

		return substr( bin2hex( random_bytes( (int) ceil( $length / 2 ) ) ), 0, $length );
	}

	/**
	 * Add activation error message for admin notice.
	 *
	 * @param  string $message Error message.
	 * @return void
	 */
	private static function add_activation_error( $message ) {
		$errors   = get_option( 'postomatic_activation_error', array() );
		$errors[] = $message;
		update_option( 'postomatic_activation_error', $errors );
	}
}
