<?php
/**
 * In-product feature discovery.
 *
 * A small, reusable per-user discovery system. Features register a discovery
 * (id + version + eligibility + bundled copy); the framework decides which
 * contextual surfaces (tab dot, in-page notice, NEW pill) to show, tracks
 * per-user dismissal/clearing state, and auto-expires every surface after a
 * fixed number of minor releases so the admin nav never accumulates permanent
 * badges. State lives in user meta — dismissal is personal, not site-global.
 *
 * @package     AffiliateWP
 * @subpackage  Admin/Discovery
 * @since       2.34.0
 */

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

/**
 * Coordinates in-product feature discovery surfaces and their per-user state.
 *
 * @since 2.34.0
 */
class AffiliateWP_Feature_Discovery {

	/**
	 * User meta key holding the per-discovery state map.
	 *
	 * @since 2.34.0
	 * @var string
	 */
	const META_KEY = 'affwp_feature_discovery';

	/**
	 * Number of minor releases a discovery stays live before it auto-expires.
	 *
	 * @since 2.34.0
	 * @var int
	 */
	const EXPIRE_AFTER_MINORS = 2;

	/**
	 * Per-user state flags (booleans) tracked per discovery id.
	 *
	 * @since 2.34.0
	 * @var array
	 */
	const STATE_FLAGS = [ 'tab_seen', 'notice_dismissed', 'notice_cta_followed', 'badge_cleared' ];

	/**
	 * Singleton instance.
	 *
	 * @since 2.34.0
	 * @var AffiliateWP_Feature_Discovery|null
	 */
	private static $instance = null;

	/**
	 * Registered discovery definitions, keyed by id.
	 *
	 * @since 2.34.0
	 * @var array
	 */
	private $discoveries = [];

	/**
	 * Whether the registration hook has fired.
	 *
	 * @since 2.34.0
	 * @var bool
	 */
	private $registered = false;

	/**
	 * Per-request memo of eligibility results, keyed by id.
	 *
	 * @since 2.34.0
	 * @var array
	 */
	private $eligibility_cache = [];

	/**
	 * Returns the singleton, firing discovery registration on first access.
	 *
	 * @since 2.34.0
	 *
	 * @return AffiliateWP_Feature_Discovery
	 */
	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}

		self::$instance->maybe_register();

		return self::$instance;
	}

	/**
	 * Fires the registration hook once so features can register their discoveries.
	 *
	 * Lazy so require-order doesn't matter: features add their callback at load,
	 * the hook fires the first time the framework is touched during a render.
	 *
	 * @since 2.34.0
	 */
	private function maybe_register() {
		if ( $this->registered ) {
			return;
		}

		$this->registered = true;

		/**
		 * Register in-product feature discoveries.
		 *
		 * Features call $discovery->register( $id, $args ) on this hook to own
		 * their own copy, eligibility, and CTA targeting while the framework
		 * stays generic.
		 *
		 * @since 2.34.0
		 *
		 * @param AffiliateWP_Feature_Discovery $discovery The discovery coordinator.
		 */
		do_action( 'affwp_register_feature_discoveries', $this );
	}

	/**
	 * Registers a discovery definition.
	 *
	 * @since 2.34.0
	 *
	 * @param string $id   Discovery id.
	 * @param array  $args {
	 *     Discovery definition.
	 *
	 *     @type string   $introduced_in Version the feature shipped in. Drives auto-expiry.
	 *     @type callable $eligibility   Returns bool; receives the id. Gates every surface.
	 *     @type string   $docs_url      "Learn more" docs URL.
	 *     @type string   $cta_target    Deep-link hash the primary CTA navigates to.
	 *     @type array    $notice        Bundled notice copy { eyebrow, heading, body, cta_label }.
	 * }
	 */
	public function register( $id, $args = [] ) {
		$id = sanitize_key( $id );

		if ( '' === $id ) {
			return;
		}

		$this->discoveries[ $id ] = wp_parse_args(
			$args,
			[
				'introduced_in' => defined( 'AFFILIATEWP_VERSION' ) ? AFFILIATEWP_VERSION : '0.0.0',
				'eligibility'   => '__return_true',
				'docs_url'      => '',
				'cta_target'    => '',
				'notice'        => [],
			]
		);
	}

	/**
	 * Returns a registered discovery definition, or null.
	 *
	 * @since 2.34.0
	 *
	 * @param string $id Discovery id.
	 * @return array|null
	 */
	public function get( $id ) {
		return isset( $this->discoveries[ $id ] ) ? $this->discoveries[ $id ] : null;
	}

	/**
	 * Returns all registered discovery ids.
	 *
	 * @since 2.34.0
	 *
	 * @return string[]
	 */
	public function get_ids() {
		return array_keys( $this->discoveries );
	}

	/**
	 * Whether the discovery is registered, in-window, and eligible for the current user.
	 *
	 * @since 2.34.0
	 *
	 * @param string $id Discovery id.
	 * @return bool
	 */
	public function is_active( $id ) {
		return null !== $this->get( $id ) && ! $this->is_expired( $id ) && $this->is_eligible( $id );
	}

	/**
	 * Runs the discovery's eligibility callback (memoized per request).
	 *
	 * @since 2.34.0
	 *
	 * @param string $id Discovery id.
	 * @return bool
	 */
	public function is_eligible( $id ) {
		if ( isset( $this->eligibility_cache[ $id ] ) ) {
			return $this->eligibility_cache[ $id ];
		}

		$def      = $this->get( $id );
		$eligible = false;

		if ( null !== $def && is_callable( $def['eligibility'] ) ) {
			$eligible = (bool) call_user_func( $def['eligibility'], $id );
		}

		$this->eligibility_cache[ $id ] = $eligible;

		return $eligible;
	}

	/**
	 * Whether the discovery has aged past its auto-expiry window.
	 *
	 * Expires once the current version reaches the introducing minor plus
	 * EXPIRE_AFTER_MINORS (e.g. introduced 2.34.0 → expires at 2.36.0).
	 *
	 * @since 2.34.0
	 *
	 * @param string $id Discovery id.
	 * @return bool
	 */
	public function is_expired( $id ) {
		$def = $this->get( $id );

		if ( null === $def || ! defined( 'AFFILIATEWP_VERSION' ) ) {
			return true;
		}

		$parts = explode( '.', preg_replace( '/[^0-9.].*$/', '', (string) $def['introduced_in'] ) );
		$major = isset( $parts[0] ) ? (int) $parts[0] : 0;
		$minor = isset( $parts[1] ) ? (int) $parts[1] : 0;

		$expires_at = sprintf( '%d.%d.0', $major, $minor + self::EXPIRE_AFTER_MINORS );

		return version_compare( AFFILIATEWP_VERSION, $expires_at, '>=' );
	}

	/**
	 * Returns the per-user state for a discovery, with all flags defaulted.
	 *
	 * @since 2.34.0
	 *
	 * @param string $id      Discovery id.
	 * @param int    $user_id User id. Defaults to current user.
	 * @return array
	 */
	public function get_state( $id, $user_id = 0 ) {
		$user_id = $user_id ? (int) $user_id : get_current_user_id();
		$all     = get_user_meta( $user_id, self::META_KEY, true );

		if ( ! is_array( $all ) ) {
			$all = [];
		}

		$state = isset( $all[ $id ] ) && is_array( $all[ $id ] ) ? $all[ $id ] : [];

		return wp_parse_args( $state, array_fill_keys( self::STATE_FLAGS, false ) );
	}

	/**
	 * Sets a per-user state flag.
	 *
	 * @since 2.34.0
	 *
	 * @param string $id      Discovery id.
	 * @param string $flag    One of STATE_FLAGS.
	 * @param bool   $value   Flag value.
	 * @param int    $user_id User id. Defaults to current user.
	 * @return bool Whether the meta was updated.
	 */
	public function set_flag( $id, $flag, $value = true, $user_id = 0 ) {
		if ( ! in_array( $flag, self::STATE_FLAGS, true ) ) {
			return false;
		}

		$user_id = $user_id ? (int) $user_id : get_current_user_id();
		$all     = get_user_meta( $user_id, self::META_KEY, true );

		if ( ! is_array( $all ) ) {
			$all = [];
		}

		if ( ! isset( $all[ $id ] ) || ! is_array( $all[ $id ] ) ) {
			$all[ $id ] = [];
		}

		$all[ $id ][ $flag ] = (bool) $value;

		return (bool) update_user_meta( $user_id, self::META_KEY, $all );
	}

	/**
	 * Whether to render the tab dot: active and the tab hasn't been opened.
	 *
	 * @since 2.34.0
	 *
	 * @param string $id Discovery id.
	 * @return bool
	 */
	public function should_show_tab_dot( $id ) {
		if ( ! $this->is_active( $id ) ) {
			return false;
		}

		$state = $this->get_state( $id );

		return empty( $state['tab_seen'] );
	}

	/**
	 * Whether to render the in-page notice: active and not yet dismissed or actioned.
	 *
	 * @since 2.34.0
	 *
	 * @param string $id Discovery id.
	 * @return bool
	 */
	public function should_show_notice( $id ) {
		if ( ! $this->is_active( $id ) ) {
			return false;
		}

		$state = $this->get_state( $id );

		return empty( $state['notice_dismissed'] ) && empty( $state['notice_cta_followed'] );
	}

	/**
	 * Whether to render NEW pills: active and not yet cleared.
	 *
	 * @since 2.34.0
	 *
	 * @param string $id Discovery id.
	 * @return bool
	 */
	public function should_show_badge( $id ) {
		if ( ! $this->is_active( $id ) ) {
			return false;
		}

		$state = $this->get_state( $id );

		return empty( $state['badge_cleared'] );
	}
}
