<?php
/**
 * License Manager
 *
 * Self-contained auth state manager for the apwp-ao domain slot flow.
 * Stores its own JWT (with slot_id claim) in a separate site option so it
 * never conflicts with the Pro Connector's single-domain JWT.
 *
 * State is stored per WordPress install using get_option() (not get_site_option())
 * so that on Multisite each subsite authorises its own domain slot independently.
 *
 * @package Agency_Pulse_Admin_Menu
 * @since   1.0.0
 */

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

class Agency_Pulse_Admin_Menu_License_Manager {

	/**
	 * Per-request state cache to avoid repeated get_option() calls.
	 *
	 * @var array|null
	 */
	private static $state_cache = null;

	/**
	 * Default state shape — all keys are always present after get_state().
	 */
	private static $defaults = array(
		'plugin_key'      => '',
		'jwt'             => '',
		'slot_id'         => 0,
		'domain_hash'     => '',
		'expires_at'      => 0,
		'status'          => 'inactive',
		'last_error'      => '',
		'last_api_error'  => '',
		'api_error_since' => 0,
	);

	// -------------------------------------------------------------------------
	// State read / write
	// -------------------------------------------------------------------------

	/**
	 * Return the current auth state, merging stored data with defaults.
	 *
	 * @since 1.0.0
	 * @return array
	 */
	public static function get_state(): array {
		if ( null !== self::$state_cache ) {
			return self::$state_cache;
		}

		$stored            = get_option( Agency_Pulse_Admin_Menu_Constants::OPTION_AO_CONNECTOR_STATE, array() );
		self::$state_cache = wp_parse_args(
			is_array( $stored ) ? $stored : array(),
			self::$defaults
		);

		return self::$state_cache;
	}

	/**
	 * Merge partial state changes into the stored state and persist.
	 *
	 * Only keys present in $changes are updated; all other keys are preserved.
	 *
	 * @since 1.0.0
	 * @param array $changes Key/value pairs to merge into the current state.
	 */
	public static function set_state( array $changes ): void {
		$current           = self::get_state();
		$merged            = array_merge( $current, $changes );
		self::$state_cache = $merged;
		update_option( Agency_Pulse_Admin_Menu_Constants::OPTION_AO_CONNECTOR_STATE, $merged, false );
	}

	/**
	 * Remove the stored auth state completely and invalidate the in-memory cache.
	 *
	 * Call on plugin deactivation or when the admin clicks "Disconnect This Site".
	 *
	 * @since 1.0.0
	 */
	public static function clear_state(): void {
		delete_option( Agency_Pulse_Admin_Menu_Constants::OPTION_AO_CONNECTOR_STATE );
		self::$state_cache = null;
	}

	public static function record_api_error( string $message ): void {
		$current     = self::get_state();
		$error_since = (int) ( $current['api_error_since'] ?? 0 );

		// No timestamp yet — first error in this run (or legacy record without
		// a timestamp). Start the clock.
		if ( 0 === $error_since ) {
			self::set_state( array(
				'last_api_error'  => $message,
				'api_error_since' => time(),
			) );
			return;
		}

		// Subsequent error: check whether the disconnect timeout has elapsed.
		$timeout_seconds = Agency_Pulse_Admin_Menu_Constants::API_ERROR_DISCONNECT_MINUTES * 60;

		if ( ( time() - $error_since ) >= $timeout_seconds ) {
			// Timeout elapsed — auto-disconnect. Preserve plugin_key so the
			// admin can reconnect from the inactive panel without re-entering it.
			self::set_state( array(
				'jwt'             => '',
				'slot_id'         => 0,
				'domain_hash'     => '',
				'expires_at'      => 0,
				'status'          => 'inactive',
				'last_error'      => $message,
				'last_api_error'  => '',
				'api_error_since' => 0,
			) );
			return;
		}

		// Within timeout — update the message (it may have changed).
		self::set_state( array( 'last_api_error' => $message ) );
	}

	public static function disconnect(): void {
		self::set_state( array(
			'jwt'             => '',
			'slot_id'         => 0,
			'domain_hash'     => '',
			'expires_at'      => 0,
			'status'          => 'inactive',
			'last_error'      => '',
			'last_api_error'  => '',
			'api_error_since' => 0,
		) );
	}

	public static function clear_api_error(): void {
		$state = self::get_state();
		if ( '' !== $state['last_api_error'] || 0 !== (int) ( $state['api_error_since'] ?? 0 ) ) {
			self::set_state( array( 'last_api_error' => '', 'api_error_since' => 0 ) );
		}
	}

	// -------------------------------------------------------------------------
	// Key / JWT accessors
	// -------------------------------------------------------------------------

	/**
	 * Return the stored plugin key.
	 *
	 * Returns an empty string if no key has been stored yet.
	 *
	 * @since 1.0.0
	 * @return string
	 */
	public static function get_plugin_key(): string {
		return self::get_state()['plugin_key'];
	}

	/**
	 * Return a valid JWT string, triggering a refresh when near soft expiry.
	 *
	 * Soft expiry window: JWT exp minus 60 seconds. If the JWT is within that
	 * window, refresh_jwt() is called. If refresh fails, the original JWT is
	 * returned — the server will issue a 401 on the next real API call, which
	 * signals to the admin that they need to reconnect.
	 *
	 * Returns '' if no JWT is stored at all.
	 *
	 * @since 1.0.0
	 * @return string
	 */
	public static function get_valid_jwt(): string {
		$state = self::get_state();

		if ( empty( $state['jwt'] ) ) {
			return '';
		}

		if ( $state['expires_at'] > 0 && time() >= $state['expires_at'] - 60 ) {
			return self::refresh_jwt();
		}

		return $state['jwt'];
	}

	/**
	 * Return true when status is 'active' and a JWT is present.
	 *
	 * This is the gate for all premium features inside apwp-ao (cloud backup, etc.).
	 * It does NOT check whether the JWT is still valid on the server — that
	 * happens naturally on the first API call after expiry.
	 *
	 * @since 1.0.0
	 * @return bool
	 */
	public static function is_active(): bool {
		return 'active' === self::get_state()['status'] && '' !== self::get_valid_jwt();
	}

	// -------------------------------------------------------------------------
	// JWT refresh
	// -------------------------------------------------------------------------

	/**
	 * POST to /api/plugin/refresh and store the new JWT on success.
	 *
	 * Returns the new JWT on success, or the existing JWT on any failure so
	 * callers always get a non-empty string when there is a stored JWT.
	 *
	 * @since 1.0.0
	 * @return string
	 */
	public static function refresh_jwt(): string {
		$state = self::get_state();

		if ( empty( $state['jwt'] ) ) {
			return '';
		}

		$base     = rtrim( defined( 'APWP_CONNECT_URL' ) ? APWP_CONNECT_URL : '', '/' );
		$response = wp_remote_post(
			$base . '/api/plugin/refresh',
			array(
				'headers' => array(
					'Authorization' => 'Bearer ' . $state['jwt'],
					'Content-Type'  => 'application/json',
				),
				'body'    => wp_json_encode( array( 'plugin_domain' => home_url() ) ),
				'timeout' => 15,
			)
		);

		if ( is_wp_error( $response ) ) {
			self::record_api_error( $response->get_error_message() );
			return $state['jwt'];
		}

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

		if ( 200 !== $refresh_code ) {
			$body  = json_decode( wp_remote_retrieve_body( $response ), true );
			$error = ( is_array( $body ) && ! empty( $body['message'] ) )
				? $body['message']
				: sprintf( 'JWT refresh failed (status %d)', $refresh_code );
			self::record_api_error( $error );
			return $state['jwt'];
		}

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

		if ( empty( $body['access_token'] ) ) {
			self::record_api_error( 'JWT refresh returned no token' );
			return $state['jwt'];
		}

		$new_jwt  = $body['access_token'];
		$claims   = self::decode_jwt_payload( $new_jwt );
		$expires  = isset( $claims['exp'] ) ? (int) $claims['exp'] : 0;

		self::set_state( array(
			'jwt'        => $new_jwt,
			'expires_at' => $expires,
		) );
		self::clear_api_error();

		return $new_jwt;
	}

	// -------------------------------------------------------------------------
	// JWT helpers
	// -------------------------------------------------------------------------

	/**
	 * Decode and return the JWT payload as an associative array.
	 *
	 * Returns an empty array on any parse failure. No signature verification
	 * is performed — the server validates the signature on every API call.
	 *
	 * @since 1.0.0
	 * @param string $jwt
	 * @return array
	 */
	public static function decode_jwt_payload( string $jwt ): array {
		$parts = explode( '.', $jwt );
		if ( 3 !== count( $parts ) ) {
			return array();
		}
		$payload = json_decode(
			base64_decode( strtr( $parts[1], '-_', '+/' ) ),
			true
		);
		return is_array( $payload ) ? $payload : array();
	}
}
