<?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 three 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.
 *
 * @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: entitlement to cross a Line boundary onto a newer version line
	 * (v14 → v15). It does **not** govern in-line updates, which are unconditional.
	 *
	 * The constant says UPGRADE and the wire value says `theme_updates` on purpose. The
	 * wire value is a server-owned identifier — never localized, never renamed, because a
	 * rename is a breaking wire change and is precisely the class of change that produced
	 * the `version` ambiguity (docs/adr/0010-client-version-and-requested-version.md). The
	 * constant carries the corrected meaning so theme-side callers read honestly.
	 */
	const THEME_UPGRADE = '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_UPGRADE`) — 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';

	/**
	 * 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' ) );

		self::sync_schedule();
	}

	/**
	 * Cron handler. Overwrite the verdict on success; leave the record untouched on failure.
	 *
	 * @return bool Whether a fresh verdict was stored.
	 */
	public function fetch() {
		$document = $this->remote_api->get_feature_access();

		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
	}

	/**
	 * 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();
	}

	/**
	 * 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() {
		$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'] );
	}
}
