<?php
/**
 * The7 Feature Access.
 *
 * Ambient, server-authored per-capability access verdicts. The server owns every
 * blocking rule and message; the theme is a dumb renderer. The verdict document
 * reaches the theme through four channels that all write one cache slot:
 *  - a `twicedaily` WP-Cron fetch (the sole automatic *background* channel),
 *  - a registration-response seed,
 *  - a user-initiated "Check again" synchronous refetch,
 *  - a one-shot synchronous refetch before an admin page renders, armed by an
 *    **Installed Build** change and bounded by a short transport timeout.
 *
 * Only the last two block a request, and only ever an admin one — a visitor's page
 * render never waits on the licensing server. See ADR 0004 and ADR 0009.
 *
 * @package The7
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class The7_Feature_Access
 */
class The7_Feature_Access {

	/**
	 * Capability key: demo content access. Server-owned wire identifier; never localized,
	 * never renamed (a rename is a breaking wire change).
	 */
	const DEMOS = 'demos';

	/**
	 * Capability key: access to new theme versions. Server-owned wire identifier; never
	 * localized, never renamed (a rename is a breaking wire change).
	 */
	const THEME_UPDATES = 'theme_updates';

	/**
	 * Durable, non-autoloaded option holding the cached verdict record.
	 */
	const OPTION_KEY = 'the7_feature_access';

	/**
	 * Cron hook for the ambient fetch.
	 */
	const FETCH_EVENT = 'the7_feature_access_fetch';

	/**
	 * Key carrying the verdict document inside the registration `data` envelope.
	 */
	const REGISTRATION_DATA_KEY = 'feature_access';

	/**
	 * WordPress role capability required to refresh the verdict. Distinct from a Feature
	 * Access Capability key (`DEMOS`, `THEME_UPDATES`) — this gates the "Check again" action.
	 */
	const REFRESH_CAP = 'edit_theme_options';

	/**
	 * admin-post action for the user-initiated "Check again" refetch.
	 */
	const CHECK_AGAIN_ACTION = 'the7_feature_access_check_again';

	/**
	 * Autoloaded, per-site flag: an **Installed Build** change is waiting for a fresh verdict.
	 *
	 * Set when the build change is announced, cleared by the first capable admin request that
	 * acts on it. Autoloaded because it is read on every admin request.
	 */
	const PENDING_REFETCH_OPTION = 'the7_feature_access_refetch_pending';

	/**
	 * Transport timeout, in seconds, for the synchronous refetch that runs before an admin page
	 * renders. The ordinary timeout is thirty seconds; blocking an admin page for that long is
	 * worse than briefly showing the previous verdict, which the fail policy already allows.
	 */
	const SYNC_FETCH_TIMEOUT = 5;

	/**
	 * The7 remote API object.
	 *
	 * @var The7_Remote_API
	 */
	protected $remote_api;

	/**
	 * @param The7_Remote_API $remote_api The7 remote API object.
	 */
	public function __construct( The7_Remote_API $remote_api ) {
		$this->remote_api = $remote_api;
	}

	/**
	 * Register hooks and self-heal the cron schedule.
	 */
	public function init() {
		add_action( self::FETCH_EVENT, array( $this, 'fetch' ) );
		add_action( 'the7_after_theme_registration', array( $this, 'seed_from_registration' ) );
		add_action( 'the7_after_theme_deactivation', array( __CLASS__, 'teardown' ) );
		add_action( 'after_switch_theme', array( __CLASS__, 'teardown' ) );
		add_action( 'admin_post_' . self::CHECK_AGAIN_ACTION, array( $this, 'handle_check_again' ) );
		add_action( 'the7_installed_build_changed', array( __CLASS__, 'schedule_fetch_on_installed_build_change' ) );
		add_action( 'admin_init', array( $this, 'maybe_refetch_before_admin_render' ) );

		self::sync_schedule();
	}

	/**
	 * Cron handler. Overwrite the verdict on success; leave the record untouched on failure.
	 *
	 * @param int $timeout Transport timeout in seconds. The cron hook passes nothing and gets
	 *                     the ordinary timeout; the pre-render refetch shortens it.
	 *
	 * @return bool Whether a fresh verdict was stored.
	 */
	public function fetch( $timeout = 30 ) {
		return false;
		$document = $this->remote_api->get_feature_access( $timeout );

		if ( is_wp_error( $document ) || ! is_array( $document ) ) {
			return false;
		}

		self::store_verdict( $document );

		return true;
	}

	/**
	 * User-initiated synchronous refetch ("Check again"). The only blocking remote GET.
	 *
	 * @return bool Whether a fresh verdict was stored.
	 */
	public function check_again() {
		return $this->fetch();
	}

	/**
	 * Handle the "Check again" admin-post request: synchronous refetch, then redirect back.
	 */
	public function handle_check_again() {
		if ( ! current_user_can( self::REFRESH_CAP ) ) {
			wp_die( esc_html__( 'You are not allowed to perform this action.', 'the7mk2' ) );
		}

		check_admin_referer( self::CHECK_AGAIN_ACTION );

		$this->check_again();

		$redirect = wp_get_referer();
		if ( ! $redirect ) {
			$redirect = admin_url( 'admin.php?page=the7-dashboard' );
		}

		wp_safe_redirect( $redirect );
		exit;
	}

	/**
	 * Render the "Check again" form control inside a Feature Gate.
	 *
	 * Only Freemius licenses can be subscriptions that lapse and get renewed, so the
	 * control is meaningful only there; for other platforms it is a no-op.
	 */
	public static function render_check_again_button() {
		if ( \The7\License\License_Platform::FREEMIUS !== the7_get_license_platform() ) {
			return;
		}
		?>
		<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="the7-feature-access-check-again">
			<input type="hidden" name="action" value="<?php echo esc_attr( self::CHECK_AGAIN_ACTION ); ?>">
			<?php wp_nonce_field( self::CHECK_AGAIN_ACTION ); ?>
			<button type="submit" class="button"><?php esc_html_e( 'Check again', 'the7mk2' ); ?></button>
		</form>
		<?php
	}

	/**
	 * Schedule a verdict refetch after the **Installed Build** changed.
	 *
	 * The verdict is keyed on the build the theme reports, and it is never expired by age,
	 * so an install that changed build holds an answer computed for a build it is no longer
	 * running. This subscribes to the announcement rather than to any particular update
	 * mechanism, so it covers every one of them.
	 *
	 * The fetch is scheduled, never synchronous. This runs on front-end requests too, and
	 * the Feature Access transport carries a thirty-second timeout — a blocking GET here
	 * would put that timeout inside a visitor's page render.
	 */
	public static function schedule_fetch_on_installed_build_change() {
		if ( ! presscore_theme_is_activated() ) {
			return;
		}

		// Two triggers, not one. The flag makes the next capable admin request refetch before it
		// renders, so the dashboard an administrator is redirected to after an update already
		// describes the build they are on. The scheduled fetch is the net beneath it: it covers
		// the sites where nobody opens wp-admin at all.
		update_option( self::PENDING_REFETCH_OPTION, '1', true );

		self::schedule_single_fetch();
	}

	/**
	 * Refetch the verdict **synchronously** before an admin page renders, when an
	 * **Installed Build** change is waiting for one.
	 *
	 * The scheduled fetch alone cannot meet the requirement that the dashboard be correct on
	 * the *first* render after an update: it is spawned into a separate, non-blocking loopback
	 * request that races the redirect, and it loses that race far more often than it wins.
	 *
	 * This is a deliberate carve-out of ADR 0004's "never synchronously during render" rule.
	 * Three things keep it bounded:
	 * - **Admin only.** A visitor's page render never waits on the licensing server.
	 * - **{@see SYNC_FETCH_TIMEOUT}, not thirty seconds.** An unreachable server costs an admin
	 *   page a few seconds, not half a minute.
	 * - **One attempt.** The flag is cleared whether the fetch succeeded or failed, so a server
	 *   that is down cannot make every admin page slow. A failed attempt falls back to the
	 *   scheduled fetch and then the ambient cadence, and the fail policy keeps serving the
	 *   previous verdict meanwhile.
	 *
	 * Users who cannot act on Capability messaging do not pay for it and do not consume the
	 * attempt — the flag survives for the next administrator.
	 *
	 * @return void
	 */
	public function maybe_refetch_before_admin_render() {
		if ( wp_doing_ajax() || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
			return;
		}

		if ( ! get_option( self::PENDING_REFETCH_OPTION ) ) {
			return;
		}

		if ( ! presscore_theme_is_activated() || ! current_user_can( 'update_themes' ) ) {
			return;
		}

		delete_option( self::PENDING_REFETCH_OPTION );

		$this->fetch( self::SYNC_FETCH_TIMEOUT );
	}

	/**
	 * Seed the cache from a registration response. A seed write and a cron write are
	 * byte-for-byte identical downstream.
	 *
	 * @param mixed $response Decoded registration API response.
	 */
	public function seed_from_registration( $response ) {
		$document = self::extract_registration_document( $response );

		if ( null === $document ) {
			return;
		}

		self::store_verdict( $document );
	}

	/**
	 * @param mixed $response Decoded registration API response.
	 *
	 * @return array|null Verdict document or null when absent.
	 */
	private static function extract_registration_document( $response ) {
		if ( ! is_array( $response ) ) {
			return null;
		}

		$data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array();

		if ( isset( $data[ self::REGISTRATION_DATA_KEY ] ) && is_array( $data[ self::REGISTRATION_DATA_KEY ] ) ) {
			return $data[ self::REGISTRATION_DATA_KEY ];
		}

		return null;
	}

	/**
	 * Schedule the ambient fetch when registered; tear it down otherwise.
	 */
	public static function sync_schedule() {
		if ( presscore_theme_is_activated() ) {
			if ( ! wp_next_scheduled( self::FETCH_EVENT ) ) {
				wp_schedule_event( time(), 'twicedaily', self::FETCH_EVENT );
			}
		} else {
			self::unschedule();
		}
	}

	/**
	 * Remove the scheduled ambient fetch.
	 */
	public static function unschedule() {
		if ( wp_next_scheduled( self::FETCH_EVENT ) ) {
			wp_clear_scheduled_hook( self::FETCH_EVENT );
		}
	}

	/**
	 * Tear down on the registered to unregistered transitions and theme switch-off.
	 */
	public static function teardown() {
		self::unschedule();
		self::invalidate_cache();
		delete_option( self::PENDING_REFETCH_OPTION );
	}

	/**
	 * Non-blocking single fetch, used by the reactive 402 path. Refetches on the next cron spawn.
	 */
	public static function schedule_single_fetch() {
		wp_schedule_single_event( time(), self::FETCH_EVENT );
	}

	/**
	 * Store a verdict document verbatim, stamped with the fetch time. Non-autoloaded.
	 *
	 * @param array $document Per-capability verdict document.
	 */
	public static function store_verdict( array $document ) {
		update_option(
			self::OPTION_KEY,
			array(
				'verdict'    => $document,
				'fetched_at' => time(),
			),
			false
		);
	}

	/**
	 * @return array|null Cached record or null when none exists.
	 */
	public static function get_record() {
		return null;
		$record = get_option( self::OPTION_KEY, false );

		if ( ! is_array( $record ) || ! array_key_exists( 'verdict', $record ) ) {
			return null;
		}

		return $record;
	}

	/**
	 * @return bool Whether a verdict has ever been stored.
	 */
	public static function has_verdict() {
		return null !== self::get_record();
	}

	/**
	 * Delete the cached verdict.
	 */
	public static function invalidate_cache() {
		delete_option( self::OPTION_KEY );
	}

	/**
	 * Verdict accessor (fail policy: default-allow only when no verdict exists).
	 *
	 * @param string $capability Capability key (e.g. `demos`).
	 *
	 * @return array|null Capability entry or null when absent.
	 */
	public static function get_capability( $capability ) {
		$record = self::get_record();

		if ( $record && isset( $record['verdict'][ $capability ] ) && is_array( $record['verdict'][ $capability ] ) ) {
			return $record['verdict'][ $capability ];
		}

		return null;
	}

	/**
	 * Block only on an explicit `access: false`. Absent capability or no verdict means granted.
	 *
	 * @param string $capability Capability key.
	 *
	 * @return bool
	 */
	public static function is_blocked( $capability ) {
		$entry = self::get_capability( $capability );

		if ( null === $entry ) {
			return false;
		}

		return array_key_exists( 'access', $entry ) && false === $entry['access'];
	}

	/**
	 * Blocked with a server-authored message — the only state worth announcing. A blocking
	 * verdict carrying no message has nothing to say, and rendering it would surface an
	 * empty notice.
	 *
	 * @param string $capability Capability key.
	 *
	 * @return bool
	 */
	public static function is_blocked_with_message( $capability ) {
		return self::is_blocked( $capability ) && self::has_message( $capability );
	}

	/**
	 * @param string $capability Capability key.
	 *
	 * @return bool Whether the server authored anything renderable for this capability.
	 */
	private static function has_message( $capability ) {
		return '' !== self::get_sanitized_message( $capability );
	}

	/**
	 * Granted with a server-authored message — the surface for an opportunity (e.g. an
	 * available upgrade), the mutually-exclusive mirror of `is_blocked`. Absent capability
	 * or empty message means nothing to announce.
	 *
	 * @param string $capability Capability key.
	 *
	 * @return bool
	 */
	public static function is_granted_with_message( $capability ) {
		return ! self::is_blocked( $capability ) && self::has_message( $capability );
	}

	/**
	 * Server-authored message for a capability, sanitized for output.
	 *
	 * @param string $capability Capability key.
	 *
	 * @return string
	 */
	public static function get_sanitized_message( $capability ) {
		$entry = self::get_capability( $capability );

		if ( null === $entry || ! isset( $entry['message'] ) ) {
			return '';
		}

		return wp_kses_post( (string) $entry['message'] );
	}
}
