<?php
/**
 * Class used to register and enqueue assets across our plugins.
 *
 * @since 4.3
 */
class Tribe__Assets {
	/**
	 * Stores all the Assets and it's configurations.
	 *
	 * @var array
	 */
	protected $assets = [];

	/**
	 * Stores the localized scripts for reference.
	 *
	 * @var array
	 */
	private $localized = [];

	/**
	 * Static Singleton Factory Method.
	 *
	 * @since 4.3
	 *
	 * @return self
	 */
	public static function instance() {
		return tribe( 'assets' );
	}

	/**
	 * Register the Methods in the correct places.
	 *
	 * @since 4.3
	 */
	public function __construct() {
		// Hook the actual registering of.
		add_action( 'init', [ $this, 'register_in_wp' ], 1, 0 );
	}

	/**
	 * Register the Assets on the correct hooks.
	 *
	 * @since 4.3
	 *
	 * @return void
	 */
	public function register_in_wp( $assets = null ) {
		if ( is_null( $assets ) ) {
			$assets = $this->assets;
		}

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

		uasort( $assets, [ $this, 'order_by_priority' ] );

		foreach ( $assets as $asset ) {
			if ( 'js' === $asset->type ) {
				wp_register_script( $asset->slug, $asset->url, $asset->deps, $asset->version, $asset->in_footer );
			} else {
				wp_register_style( $asset->slug, $asset->url, $asset->deps, $asset->version, $asset->media );
			}

			// Register that this asset is actually registered on the WP methods.
			$asset->is_registered = true;

			// If we don't have an action we don't even register the action to enqueue.
			if ( empty( $asset->action ) ) {
				continue;
			}

			// Now add an action to enqueue the registered assets.
			foreach ( (array) $asset->action as $action ) {
				// Enqueue the registered assets at the appropriate time.
				if ( did_action( $action ) > 0 ) {
					$this->enqueue();
				} else {
					add_action( $action, [ $this, 'enqueue' ], $asset->priority );
				}
			}
		}
	}

	/**
	 * Enqueues registered assets based on their groups.
	 *
	 * @since   4.7
	 *
	 * @uses    self::enqueue
	 *
	 * @param   string|array $groups Which groups will be enqueued.
	 *
	 * @return  void
	 */
	public function enqueue_group( $groups ) {
		$assets = $this->get();
		$enqueue = [];

		foreach ( $assets as $asset ) {
			if ( empty( $asset->groups ) ) {
				continue;
			}

			$instersect = array_intersect( (array) $groups, $asset->groups );

			if ( empty( $instersect ) ) {
				continue;
			}

			$enqueue[] = $asset->slug;
		}

		$this->enqueue( $enqueue );
	}

	/**
	 * Enqueues registered assets.
	 *
	 * This method is called on whichever action (if any) was declared during registration.
	 *
	 * It can also be called directly with a list of asset slugs to forcibly enqueue, which may be
	 * useful where an asset is required in a situation not anticipated when it was originally
	 * registered.
	 *
	 * @since 4.3
	 *
	 * @param string|array $forcibly_enqueue
	 */
	public function enqueue( $forcibly_enqueue = null ) {
		$forcibly_enqueue = array_filter( (array) $forcibly_enqueue );
		$assets = $this->get();

		foreach ( $assets as $asset ) {
			// Should this asset be enqueued regardless of the current filter/any conditional requirements?
			$must_enqueue = in_array( $asset->slug, $forcibly_enqueue );
			$in_filter    = in_array( current_filter(), (array) $asset->action );

			// Skip if we are not on the correct filter (unless we are forcibly enqueuing).
			if ( ! $in_filter && ! $must_enqueue ) {
				continue;
			}

			// If any single conditional returns true, then we need to enqueue the asset.
			if ( empty( $asset->action ) && ! $must_enqueue ) {
				continue;
			}

			// If this asset was late called
			if ( ! $asset->is_registered ) {
				$this->register_in_wp( $asset );
			}

			// Default to enqueuing the asset if there are no conditionals,
			// and default to not enqueuing it if there *are* conditionals.
			$enqueue = empty( $asset->conditionals );

			if ( ! $enqueue ) {
				// Reset Enqeue
				$enqueue = [];

				// Which is the operator?
				$conditional_operator = Tribe__Utils__Array::get( $asset->conditionals, 'operator', 'OR' );

				// If we have a set of conditionals we loop on then and get if they are true.
				foreach ( $asset->conditionals as $key => $conditional ) {
					// Avoid doing anything to the operator
					if ( 'operator' === $key ) {
						continue;
					}

					$enqueue[] = call_user_func( $conditional );
				}

				// By default we use OR for backwards compatibility.
				if ( 'OR' === $conditional_operator ) {
					$enqueue = in_array( true, $enqueue );
				} else {
					$enqueue = ! in_array( false, $enqueue );
				}
			}

			/**
			 * Allows developers to hook-in and prevent an asset from been loaded.
			 *
			 * @since 4.3
			 *
			 * @param bool   $enqueue If we should enqueue or not a given asset.
			 * @param object $asset   Which asset we are dealing with.
			 */
			$enqueue = apply_filters( 'tribe_asset_enqueue', $enqueue, $asset );

			/**
			 * Allows developers to hook-in and prevent an asset from been loaded
			 *
			 * @since 4.3
			 *
			 * @param bool   $enqueue If we should enqueue or not a given asset.
			 * @param object $asset   Which asset we are dealing with.
			 */
			$enqueue = apply_filters( "tribe_asset_enqueue_{$asset->slug}", $enqueue, $asset );

			if ( ! $enqueue && ! $must_enqueue ) {
				continue;
			}

			if ( 'js' === $asset->type ) {
				wp_enqueue_script( $asset->slug );

				// Only localize on JS and if we have data.
				if ( ! empty( $asset->localize ) ) {
					// Makes sure we have an Array of Localize data.
					if ( is_object( $asset->localize ) ) {
						$localization = [ $asset->localize ];
					} else {
						$localization = (array) $asset->localize;
					}

					/**
					 * Check to ensure we haven't already localized it before.
					 *
					 * @since 4.5.8
					 */
					foreach ( $localization as $localize ) {
						if ( in_array( $localize->name, $this->localized ) ) {
							continue;
						}

						// If we have a Callable as the Localize data we execute it.
						if ( is_callable( $localize->data ) ) {
							$localize->data = call_user_func( $localize->data, $asset );
						}

						wp_localize_script( $asset->slug, $localize->name, $localize->data );
						$this->localized[] = $localize->name;
					}
				}
			} else {
				wp_enqueue_style( $asset->slug );
			}

			$asset->already_enqueued = true;
		}
	}

	/**
	 * Returns the path to a minified version of a js or css file, if it exists.
	 * If the file does not exist, returns false.
	 *
	 * @since 4.3
	 * @since 4.5.10 Removed ability to pass a filepath as $url
	 *
	 * @param string $url The absolute URL to the un-minified file.
	 *
	 * @return string|false The url to the minified version or false, if file not found.
	 */
	public static function maybe_get_min_file( $url ) {
		$urls            = [];
		$wpmu_plugin_url = set_url_scheme( WPMU_PLUGIN_URL );
		$wp_plugin_url   = set_url_scheme( WP_PLUGIN_URL );
		$wp_content_url  = set_url_scheme( WP_CONTENT_URL );
		$plugins_url     = plugins_url();

		if ( 0 === strpos( $url, $wpmu_plugin_url ) ) {
			// URL inside WPMU plugin dir.
			$base_dir = wp_normalize_path( WPMU_PLUGIN_DIR );
			$base_url = $wpmu_plugin_url;
		} elseif ( 0 === strpos( $url, $wp_plugin_url ) ) {
			// URL inside WP plugin dir.
			$base_dir = wp_normalize_path( WP_PLUGIN_DIR );
			$base_url = $wp_plugin_url;
		} elseif ( 0 === strpos( $url, $wp_content_url ) ) {
			// URL inside WP content dir.
			$base_dir = wp_normalize_path( WP_CONTENT_DIR );
			$base_url = $wp_content_url;
		} elseif ( 0 === strpos( $url, $plugins_url ) ) {
			$base_dir = wp_normalize_path( WP_PLUGIN_DIR );
			$base_url = $plugins_url;
		} else {
			// Resource needs to be inside wp-content or a plugins dir.
			return false;
		}

		// Strip the plugin URL and make this relative.
		$relative_location = str_replace( $base_url, '', $url );

		// If needed add the Min Files.
		if ( ! defined( 'SCRIPT_DEBUG' ) || SCRIPT_DEBUG === false ) {
			if ( substr( $relative_location, - 3, 3 ) === '.js' ) {
				$urls[] = substr_replace( $relative_location, '.min', - 3, 0 );
			}

			if ( substr( $relative_location, - 4, 4 ) === '.css' ) {
				$urls[] = substr_replace( $relative_location, '.min', - 4, 0 );
			}
		}

		// Add the actual url after having the min file added.
		$urls[] = $relative_location;

		// Check for all Urls added to the array.
		foreach ( $urls as $partial_path ) {
			$file_path = wp_normalize_path( $base_dir . $partial_path );
			$file_url  = plugins_url( basename( $file_path ), $file_path );

			if ( file_exists( $file_path ) ) {
				return $file_url;
			}
		}

		// If we don't have any real file return false.
		return false;
	}

	/**
	 * Register an Asset and attach a callback to the required action to display it correctly.
	 *
	 * @since 4.3
	 *
	 * @param  object       $origin    The main Object for the plugin you are enqueueing the script/style for.
	 * @param  string       $slug      Slug to save the asset.
	 * @param  string       $file      Which file will be loaded, either CSS or JS.
	 * @param  array        $deps      Dependencies.
	 * @param  string|null  $action    (Optional) A WordPress Action, if set needs to happen after: `wp_enqueue_scripts`, `admin_enqueue_scripts`, or `login_enqueue_scripts`.
	 * @param  string|array $arguments {
	 *     Optional. Array or string of parameters for this asset.
	 *
	 *     @type string|null  $action         Which WordPress action this asset will be loaded on.
	 *     @type int          $priority       Priority in which this asset will be loaded on the WordPress action.
	 *     @type string       $file           The relative path to the File that will be enqueued, uses the $origin to get the full path.
	 *     @type string       $type           Asset Type, `js` or `css`.
	 *     @type array        $deps           An array of other asset as dependencies.
	 *     @type string       $version        Version number, used for cache expiring.
	 *     @type string       $media          Used only for CSS, when to load the file.
	 *     @type bool         $in_footer      A boolean determining if the javascript should be loaded on the footer.
	 *     @type array|object $localize       Variables needed on the JavaScript side {
	 *          @type string 		$name     Name of the JS variable.
	 *          @type string|array  $data     Contents of the JS variable.
	 *     }
	 *     @type callable[]   $conditionals   An callable method or an array of them, that will determine if the asset is loaded or not.
	 * }
	 *
	 * @return string
	 */
	public function register( $origin, $slug, $file, $deps = [], $action = null, $arguments = [] ) {
		// Prevent weird stuff here.
		$slug = sanitize_title_with_dashes( $slug );

		if ( $this->exists( $slug ) ) {
			return $this->get( $slug );
		}

		if ( is_string( $origin ) ) {
			// Origin needs to be a class with a `instance` method and a Version constant.
			if ( class_exists( $origin ) && method_exists( $origin, 'instance' ) && defined( $origin . '::VERSION' ) ) {
				$origin = call_user_func( [ $origin, 'instance' ] );
			}
		}

		if ( is_object( $origin ) ) {
			$origin_name = get_class( $origin );

			if ( ! defined( $origin_name . '::VERSION' ) ) {
				// If we have a Object and we don't have instance or version.
				return false;
			}
		} else {
			return false;
		}

		// Fetches the version on the Origin Version constant.
		$version = constant( $origin_name . '::VERSION' );

		// Default variables to prevent notices.
		$defaults = [
			'slug'          => null,
			'file'          => false,
			'url'           => false,
			'action'        => null,
			'priority'      => 10,
			'type'          => null,
			'deps'          => [],
			'groups'        => [],
			'version'       => $version,
			'media'         => 'all',
			'in_footer'     => true,
			'is_registered' => false,
			'origin_path'   => null,
			'origin_url'    => null,
			'origin_name'   => null,

			// Bigger Variables at the end.
			'localize'      => [],
			'conditionals'  => [],
		];

		// Merge Arguments.
		$asset = (object) wp_parse_args( $arguments, $defaults );

		// Enforce these one.
		$asset->slug        = $slug;
		$asset->file        = $file;
		$asset->deps        = $deps;
		$asset->action      = $action;
		$asset->origin_path = trailingslashit( ! empty( $origin->plugin_path ) ? $origin->plugin_path : $origin->pluginPath );
		$asset->origin_name = $origin_name;

		// Origin URL might throw notices so we double check.
		$asset->origin_url  = ! empty( $origin->plugin_url ) ? $origin->plugin_url : null;
		$asset->origin_url  = ! empty( $origin->pluginUrl ) ? $origin->pluginUrl : null;
		if ( ! empty( $asset->origin_url ) ) {
			$asset->origin_url = trailingslashit( $asset->origin_url );
		}

		// If we don't have a type on the arguments we grab from the File path.
		if ( is_null( $asset->type ) ) {
			if ( substr( $asset->file, -3, 3 ) === '.js' ) {
				$asset->type = 'js';
			} elseif ( substr( $asset->file, -4, 4 ) === '.css' ) {
				$asset->type = 'css';
			}
		}

		// If asset type is wrong don't register.
		if ( ! in_array( $asset->type, [ 'js', 'css' ] ) ) {
			return false;
		}

		/**
		 * Deprecated filter to allow changing version based on the type of Asset.
		 *
		 * @todo remove on 4.6
		 * @deprecated 4.3
		 *
		 * @param string $version
		 */
		$asset->version = apply_filters( "tribe_events_{$asset->type}_version", $asset->version );

		/**
		 * Filter to change version number on assets.
		 *
		 * @since 4.3
		 *
		 * @param string $version
		 * @param object $asset
		 */
		$asset->version = apply_filters( 'tribe_asset_version', $asset->version, $asset );

		// Clean these
		$asset->priority  = absint( $asset->priority );
		$asset->in_footer = (bool) $asset->in_footer;
		$asset->media     = esc_attr( $asset->media );

		// Ensures that we have a priority over 1.
		if ( $asset->priority < 1 ) {
			$asset->priority = 1;
		}

		$is_vendor = strpos( $asset->file, 'vendor/' ) !== false ? true : false;

		// Setup the actual URL.
		if ( filter_var( $asset->file, FILTER_VALIDATE_URL ) ) {
			$asset->url = $asset->file;
		} else {
			$asset->url = $this->maybe_get_min_file( tribe_resource_url( $asset->file, false, ( $is_vendor ? '' : null ), $origin ) );
		}

		// Parse the Localize asset arguments.
		$asset = $this->parse_argument_localize( $asset );

		// Looks for a single conditional callable and places it in an Array.
		if ( ! empty( $asset->conditionals ) && is_callable( $asset->conditionals ) ) {
			$asset->conditionals = [ $asset->conditionals ];
		}

		// Groups is always an array of unique strings.
		if ( ! empty( $asset->groups ) ) {
			$asset->groups = (array) $asset->groups;
			$asset->groups = array_filter( $asset->groups, 'is_string' );
			$asset->groups = array_unique( $asset->groups );
		}

		/**
		 * Filter an Asset loading variables.
		 *
		 * @since  4.3
		 *
		 * @param object $asset
		 */
		$asset = apply_filters( 'tribe_asset_pre_register', $asset );

		// Set the Asset on the array of notices.
		$this->assets[ $slug ] = $asset;

		// Sorts by priority.
		uasort( $this->assets, [ $this, 'order_by_priority' ] );

		// Return the Slug because it might be modified.
		return $asset;
	}

	/**
	 * Parse the localize argument for a given asset object.
	 *
	 * @since 4.9.12
	 *
	 * @param  stdClass $asset Argument that set that asset.
	 *
	 * @return stdClass
	 */
	public function parse_argument_localize( stdClass $asset ) {
		if ( empty( $asset->localize ) ) {
			return $asset;
		}

		if ( ! is_array( $asset->localize ) && ! is_object( $asset->localize ) ) {
			return $asset;
		}

		// Cast to array for safety.
		$asset->localize = (array) $asset->localize;

		// Allow passing of a single instance.
		if ( ! empty( $asset->localize['name'] ) ) {
			// Reset to empty when name was not empty data was not set.
			$asset->localize = ! isset( $asset->localize['data'] ) ? [] : [ (object) $asset->localize ];
		}

		// Cast all instances as object.
		$asset->localize = array_map( function( $values ) {
			return (object) $values;
		}, $asset->localize );

		return $asset;
	}

	/**
	 * Removes an Asset from been registered and enqueue.
	 *
	 * @since 4.3
	 *
	 * @param  string $slug Slug of the Asset.
	 *
	 * @return bool
	 */
	public function remove( $slug ) {
		if ( ! $this->exists( $slug ) ) {
			return false;
		}

		unset( $this->assets[ $slug ] );
		return true;
	}

	/**
	 * Get the Asset Object configuration.
	 *
	 * @since 4.3
	 *
	 * @param  string $slug Slug of the Asset.
	 *
	 * @return bool
	 */
	public function get( $slug = null ) {
		uasort( $this->assets, [ $this, 'order_by_priority' ] );

		if ( is_null( $slug ) ) {
			return $this->assets;
		}

		// Prevent weird stuff here.
		$slug = sanitize_title_with_dashes( $slug );

		if ( ! empty( $this->assets[ $slug ] ) ) {
			return $this->assets[ $slug ];
		}

		return null;
	}

	/**
	 * Add the Priority ordering, which was causing an issue of not respecting which order stuff was registered.
	 *
	 * @since  4.7
	 *
	 * @param  object  $a  First Subject to compare.
	 * @param  object  $b  Second subject to compare.
	 *
	 * @return boolean
	 */
	public function order_by_priority( $a, $b ) {
		return (int) $a->priority === (int) $b->priority ? 0 : (int) $a->priority > (int) $b->priority;
	}

	/**
	 * Checks if an Asset exists.
	 *
	 * @param  string $slug Slug of the Asset.
	 *
	 * @return bool
	 */
	public function exists( $slug ) {
		return is_object( $this->get( $slug ) ) ? true : false;
	}
}
