<?php
defined( 'WPINC' ) || die; // Do not load directly.

/**
 * Base Extension class
 *
 * Avoid using static states within this class unless it's global for all extensions.
 * Some extension authors may lack a thorough understanding of OOP and inheritance.
 * This is built with such ones in mind.
 *
 * @package Tribe
 * @subpackage Extension
 * @since 4.3.1
 */
abstract class Tribe__Extension {

	/**
	 * Extension arguments
	 *
	 * @var array {
	 *      Each argument for this extension instance
	 *
	 *      @type string $version Extension's semantic version number.
	 *                            Can be manually set in child to boost performance.
	 *
	 *      @type string $url     Extension's tec.com page.
	 *
	 *      @type string $hook    Action/hook to fire init() on.
	 *
	 *      @type string $file    File path containing plugin header.
	 *                            Can be manually set in child to boost performance.
	 *
	 *      @type string $class   Extension's main class name.
	 *
	 *      @type array  $requires {
	 *          Each plugin this extension requires
	 *
	 *          @type string $main_class Minimum version number
	 *      }
	 *
	 *      @type array $plugin_data If the plugin file header is parsed, the
	 *                               resulting data is stored in this.
	 * }
	 */
	protected $args = [];

	/**
	 * The various extension instances
	 *
	 * @var array {
	 *      Each instance of an extension that extends this class
	 *
	 *      @type object $child_class_name instance
	 * }
	 */
	private static $instances = [];

	/**
	 * Get singleton instance of child class
	 *
	 * @param string $child_class (optional) Name of child class.
	 * @param array $args         (optional) Any args that should be set on construct.
	 *                            Only used the first time the extension is instantiated.
	 *
	 * @return object|null The extension's instance, or null if it can't be instantiated
	 */
	public static function instance( $child_class = null, $args = null ) {
		// Defaults to the name of the class that called this instance.
		$child_class = empty( $child_class ) ? self::get_called_class() : $child_class;

		// @see self::get_called_class() for the reason why this could be empty
		if ( empty( $child_class ) ) {
			return null;
		}

		if ( ! isset( self::$instances[ $child_class ] ) ) {
			$args = (array) $args;
			$args['class'] = $child_class;

			self::$instances[ $child_class ] = new $child_class( $args );
		}

		return self::$instances[ $child_class ];
	}

	/**
	 * Initializes the extension.
	 *
	 * Waits until after the init hook has fired.
	 *
	 * @param array $args The full path to the plugin file.
	 */
	final private function __construct( array $args ) {
		$this->args = $args;
		$this->construct();

		// The init() action/hook.
		$init_hook = $this->get_init_hook();

		// Continue plugin run after $init_hook has fired.
		if ( did_action( $init_hook ) > 0 ) {
			$this->register();
		} else {
			add_action( $init_hook, [ $this, 'register' ] );
		}
	}

	/**
	 * Empty function typically overridden by child class
	 */
	protected function construct() {}

	/**
	 * This is where the magic begins
	 *
	 * Declare this inside the child and put any custom code inside of it.
	 */
	abstract public function init();

	/**
	 * Adds a Tribe Plugin to the list of plugins this extension depends upon.
	 *
	 * If this plugin is not present or does not exceed the specified version
	 * init() will not run.
	 *
	 * @param string      $main_class      The Main class for this Tribe plugin.
	 * @param string|null $minimum_version Minimum acceptable version of plugin.
	 */
	final protected function add_required_plugin( $main_class, $minimum_version = null ) {
		$this->set( [ 'requires', $main_class ], $minimum_version );
	}

	/**
	 * Set the extension's tec.com URL
	 *
	 * @param string $url URL to the extension's page.
	 */
	final protected function set_url( $url ) {
		$this->set( 'url', $url );

		// Adds this as a tutorial <a> link to Wp Admin > Plugins page.
		Tribe__Plugin_Meta_Links::instance()->add_link(
			$this->get_plugin_file(),
			__( 'Tutorial', 'tribe-common' ),
			$url,
			[ 'class' => 'tribe-meta-link-extension' ]
		);
	}

	/**
	 * Set the extension's version number
	 *
	 * @param string $version Extensions semantic version number.
	 */
	final protected function set_version( $version ) {
		$this->set( 'version', $version );
	}

	/**
	 * Checks if the extension has permission to run, if so runs init() in child class
	 */
	final public function register() {
		$extension_file       = $this->get_plugin_file();
		$extension_class_name = $this->get( 'class' );
		$extension_version    = $this->get_version();
		$plugins_required     = $this->get( 'requires', [] );

		tribe_register_plugin(
			$extension_file,
			$extension_class_name,
			$extension_version,
			$plugins_required
		);

		$dependency = Tribe__Dependency::instance();

		// check requisite plugins are active for this extension
		$is_plugin_authorized = $dependency->has_requisite_plugins( $plugins_required );

		/**
		 * Explicitly disallow an extension, such as a core plugin having absorbed/replaced its functionality.
		 *
		 * @since 4.12.2
		 *
		 * @param bool             $is_disallowed        False by default.
		 * @param string           $extension_class_name This extension's class name string
		 *                                               (without initial forward slash for namespaced classes).
		 * @param Tribe__Extension $instance             The extension class instance.
		 */
		$is_disallowed = (bool) apply_filters( 'tribe_extension_is_disallowed', false, $extension_class_name, $this );

		if ( $is_disallowed ) {
			if (
				is_admin()
				&& current_user_can( 'activate_plugins' )
			) {
				tribe_notice( 'tribe_extension_is_disallowed', [ $this, 'notice_disallowed' ], [ 'type' => 'error' ] );
			}

			deactivate_plugins( $extension_file, true );

			return;
		}

		if ( $is_plugin_authorized ) {
			$this->init();

			// Add extension as active to dependency checker.
			$dependency->add_active_plugin( $extension_class_name, $extension_version, $extension_file );
		}
	}

	/**
	 * Gets the full path to the extension's plugin file
	 *
	 * Sets default if the arg is blank.
	 *
	 * @return string File path
	 */
	final public function get_plugin_file() {
		$file = $this->get( 'file' );

		// If this is not set assume the extension's plugin class is the plugin file.
		if ( empty( $file ) ) {
			$reflection = new ReflectionClass( $this->get( 'class' ) );
			$file = $reflection->getFileName();
			$this->set( 'file', $file );
		}

		return $file;
	}

	/**
	 * Get the extension's version number
	 *
	 * @return string Semantic version number
	 */
	final public function get_version() {
		return $this->get_arg_or_plugin_data( 'version', 'Version' );
	}

	/**
	 * Get the extension's plugin name
	 *
	 * @return string Plugin name
	 */
	final public function get_name() {
		return $this->get_arg_or_plugin_data( 'name', 'Name' );
	}

	/**
	 * Get the extension's description
	 *
	 * @return string Plugin description
	 */
	final public function get_description() {
		return $this->get_arg_or_plugin_data( 'description', 'Description' );
	}

	/**
	 * Gets the action/hook for the extensions' init().
	 *
	 * @return string Action/hook
	 */
	final public function get_init_hook() {
		return $this->get( 'hook', 'tribe_plugins_loaded' );
	}

	/**
	 * Gets the plugin data from the plugin file header
	 *
	 * This is somewhat resource intensive, so data is stored in $args
	 * in case of subsequent calls.
	 *
	 * @see get_plugin_data() for WP Admin only function this is similar to.
	 *
	 * @return array Plugin data; keys match capitalized file header declarations.
	 */
	final public function get_plugin_data() {
		$plugin_data = $this->get( 'plugin_data' );

		// Set the plugin data arg/cache to match.
		if ( empty( $plugin_data ) ) {
			$plugin_data = $this->set( 'plugin_data', Tribe__Utils__Plugins::get_plugin_data( $this->get_plugin_file() ) );
		}

		return $plugin_data;
	}

	/**
	 * Retrieves any args whose default value is stored in the plugin file header
	 *
	 * @param string $arg             The key for arg.
	 * @param string $plugin_data_key The key for the arg in the file header.
	 *
	 * @return string|null String if set, otherwise null.
	 */
	final public function get_arg_or_plugin_data( $arg, $plugin_data_key ) {
		$arg_value = $this->get( $arg, null );

		// See if the arg is already set, if not get default from plugin data and set it.
		if ( null === $arg_value ) {
			$pdata = $this->get_plugin_data();
			$arg_value = isset( $pdata[ $plugin_data_key ] ) ? $pdata[ $plugin_data_key ] : null;
		}

		return $arg_value;
	}

	/**
	 * Sets an arg, including one nested a few levels deep
	 *
	 * @param string|array $key    To set an arg nested multiple levels deep pass an array
	 *                             specifying each key in order as a value.
	 *                             Example: array( 'lvl1', 'lvl2', 'lvl3' );
	 * @param mixed         $value The value.
	 */
	final protected function set( $key, $value ) {
		$this->args = Tribe__Utils__Array::set( $this->args, $key, $value );
	}

	/**
	 * Retrieves arg, including one nested a few levels deep
	 *
	 * @param string|array $key     To select an arg nested multiple levels deep pass an
	 *                              array specifying each key in order as a value.
	 *                              Example: array( 'lvl1', 'lvl2', 'lvl3' );
	 * @param null         $default Value to return if nothing is set.
	 *
	 * @return mixed Returns the args value or the default if arg is not found.
	 */
	final public function get( $key, $default = null ) {
		return Tribe__Utils__Array::get( $this->args, $key, $default );
	}

	/**
	 * Gets the name of the class the method is called in; typically will be a child class
	 *
	 * This uses some hackery if the server is on PHP 5.2, and it can fail in rare
	 * circumstances causing a null value to be returned.
	 *
	 * @return string|null Class name
	 */
	final protected static function get_called_class() {
		$class_name = null;

		if ( function_exists( 'get_called_class' ) ) {
			// For PHP 5.3+ we can use the late static binding class name.
			$class_name = get_called_class();
		} else {
			// For PHP 5.2 and under we hack around the lack of late static bindings.
			try {
				$backtraces = debug_backtrace();

				// Grab each class from the backtrace.
				foreach ( $backtraces as $i ) {
					$class = null;

					if ( array_key_exists( 'class', $i ) ) {
						// Direct call to a class.
						$class = $i['class'];
					} elseif (
						array_key_exists( 'function', $i ) &&
						strpos( $i['function'], 'call_user_func' ) === 0 &&
						array_key_exists( 'args', $i ) &&
						is_array( $i['args'] ) &&
						is_array( $i['args'][0] ) &&
						isset( $i['args'][0][0] )
					) {
						// Found a call from call_user_func... and $i['args'][0][0] is present
						// indicating a static call to a method.
						$class = $i['args'][0][0];
					} else {
						// Slight performance boost from skipping ahead.
						continue;
					}

					// Check to see if the parent is the current class.
					// The first backtrace with a matching parent is our class.
					if ( get_parent_class( $class ) === __CLASS__ ) {
						$class_name = $class;
						break;
					}
				}
			} catch ( Exception $e ) {
				// Host has disabled or misconfigured debug_backtrace().
				$exception = new Tribe__Exception( $e );
				$exception->handle();
			}
		}

		// Class name was not set by debug_backtrace() hackery.
		if ( null === $class_name ) {
			tribe_notice( 'tribe_debug_backtrace_disabled', [ __CLASS__, 'notice_debug_backtrace' ] );
		}

		return $class_name;
	}

	/**
	 * Echoes error message indicating user is on PHP 5.2 and debug_backtrace is disabled
	 */
	final public static function notice_debug_backtrace() {
		printf(
			'<p>%s</p>',
			esc_html__( 'Unable to run Tribe Extensions. Your website host is running PHP 5.2 or older, and has likely disabled or misconfigured debug_backtrace(). You, or your website host, will need to upgrade PHP or properly configure debug_backtrace() for Tribe Extensions to work.', 'tribe-common' )
		);
	}

	/**
	 * Gets the error message about being explicitly disallowed.
	 *
	 * @since 4.12.2
	 *
	 * @return string Notice text.
	 */
	public function notice_disallowed() {
		return sprintf(
			'<p><strong>%1$s:</strong> %2$s</p>',
			$this->get_name(),
			esc_html_x(
				"This extension has been programmatically disallowed. The most common reason is due to another The Events Calendar plugin having absorbed or replaced this extension's functionality. This extension plugin has been deactivated, and you should likely delete it.",
				'extension disallowed',
				'tribe-common'
			)
		);
	}

	/**
	 * Prevent cloning the singleton with 'clone' operator
	 *
	 * @return void
	 */
	final public function __clone() {
		_doing_it_wrong(
			__FUNCTION__,
			'Can not use this method on singletons.',
			'4.3'
		);
	}

	/**
	 * Prevent unserializing the singleton instance
	 *
	 * @return void
	 */
	final public function __wakeup() {
		_doing_it_wrong(
			__FUNCTION__,
			'Can not use this method on singletons.',
			'4.3'
		);
	}
}
