<?php
/**
 * GAS Framework class
 *
 * @package GAS Framework
 */

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

/**
 * GASFramework class
 *
 * @since 1.0
 */
class GASFramework {

	/**
	 * All GASFramework instances
	 *
	 * @var array
	 */
	private static $instances = array();

  /**
   * The current blog id
   * @var string
   */
  private $blogId;

	/**
	 * The current option namespace.
	 * Options will be prefixed with this in the database
	 *
	 * @var string
	 */
	public $optionNamespace;

	/**
	 * All main containers (admin pages, meta boxes, customizer section)
	 *
	 * @var array of GASFrameworkAdminPage, GASFrameworkMetaBox, & GASFrameworkCustomizer
	 */
	private $mainContainers = array();

	/**
	 * All Google Font options used. This is for enqueuing Google Fonts for the frontend
	 * TODO Move this to the GASFrameworkOptionSelectGooglefont class and let it enqueue from there
	 *
	 * @var array GASFrameworkOptionSelectGooglefont
	 */
	private $googleFontsOptions = array();

	/**
	 * We store option ids which should not be created here
	 *
	 * @var array
	 *
	 * @see removeOption()
	 */
	private $optionsToRemove = array();

	/**
	 * Holds the values of all admin (page & tab) options. We need this since
	 *
	 * @var array of GASFrameworkOption
	 */
	private $adminOptions;

	/**
	 * The CSS class instance used
	 *
	 * @var GASFrameworkCSS
	 */
	public $cssInstance;

	/**
	 * We store the options (with IDs) here, used for ensuring our serialized option
	 * value doesn't get cluttered with unused options
	 *
	 * @var array
	 */
	public $optionsUsed = array();

	/**
	 * The current list of settings
	 *
	 * @var array
	 */
	public $settings = array();

	/**
	 * Default settings
	 *
	 * @var array
	 */
	private $defaultSettings = array(
		'css' => 'generate', 	// If 'generate', GAS will try and generate a cacheable
		                        // CSS file (or inline if it can't).
			 					// If 'inline', CSS will be printed out in the head tag,
								// If false, CSS will not be generated nor printed.
	);


	/**
	 * Gets an instance of the framework for the namespace
	 *
	 * @since 1.0
	 *
	 * @param string $optionNamespace The namespace to get options from.
	 *
	 * @return GASFramework
	 */
	public static function getInstance( $optionNamespace ) {

		// Clean namespace.
		$optionNamespace = str_replace( ' ', '-', trim( strtolower( $optionNamespace ) ) );

		foreach ( self::$instances as $instance ) {
			if ( $instance->optionNamespace == $optionNamespace ) {
				return $instance;
			}
		}

		$newInstance = new GASFramework( $optionNamespace );
		self::$instances[] = $newInstance;
		return $newInstance;
	}


	/**
	 * Gets all active instances of GAS Framework
	 *
	 * @since 1.9.2
	 *
	 * @return array An array of GASFramework objects
	 */
	public static function getAllInstances() {
		return self::$instances;
	}


	/**
	 * Creates a new GASFramework object
	 *
	 * @since 1.0
	 *
	 * @param string $optionNamespace The namespace to get options from.
	 */
	function __construct( $optionNamespace ) {

    // Set current blog
    $this->blogId = get_current_blog_id();

		// Clean namespace.
		$optionNamespace = str_replace( ' ', '-', trim( strtolower( $optionNamespace ) ) );

		$this->optionNamespace = $optionNamespace;
		$this->settings = $this->defaultSettings;

		do_action( 'tf_init', $this );
		do_action( 'tf_init_' . $this->optionNamespace, $this );

		$this->cssInstance = new GASFrameworkCSS( $this );

		add_action( 'admin_enqueue_scripts', array( $this, 'loadAdminScripts' ) );
		add_action( 'tf_create_option_' . $this->optionNamespace, array( $this, 'rememberAllOptions' ) );
		add_filter( 'tf_create_option_continue_' . $this->optionNamespace, array( $this, 'removeChildThemeOptions' ), 10, 2 );

		// Create a save option filter for customizer options.
		add_filter( 'pre_update_option', array( $this, 'addCustomizerSaveFilter' ), 10, 3 );
	}


	/**
	 * Action hook on tf_create_option to remember all the options, used to ensure that our
	 * serialized option does not get cluttered with unused options
	 *
	 * @since 1.2.1
	 *
	 * @param GASFrameworkOption $option The option that was just created.
	 *
	 * @return void
	 */
	public function rememberAllOptions( $option ) {
		if ( ! empty( $option->settings['id'] ) ) {		
			
			if ( is_admin() && isset( $this->optionsUsed[ $option->settings['id'] ] ) ) {
				
				// translators: %s is the ticket id.
				$x_content = __( 'All option IDs per namespace must be unique. The id %s has been used multiple times.',  'awesome-support'  );
				self::displayFrameworkError(
					sprintf( $x_content,
						'<code>' . $option->settings['id'] . '</code>'
					)
				);
			}

			$this->optionsUsed[ $option->settings['id'] ] = $option;
		}
	}


	/**
	 * Loads all the admin scripts used by GAS Framework
	 *
	 * @since 1.0
	 *
	 * @param string $hook The slug of admin page that called the enqueue.
	 *
	 * @return void
	 */
	public function loadAdminScripts( $hook ) {

		// Get all options panel IDs.
		$panel_ids = array();
		if ( ! empty( $this->mainContainers['admin-page'] ) ) {
			foreach ( $this->mainContainers['admin-page'] as $admin_panel ) {
				$panel_ids[] = $admin_panel->panelID;
			}
		}

		// Only enqueue scripts if we're on a GAS options page.
		if ( in_array( $hook, $panel_ids ) || ! empty( $this->mainContainers['meta-box'] ) ) {
			wp_enqueue_media();
			wp_enqueue_script( 'tf-serialize', GASFramework::getURL( '../js/min/serialize-min.js', __FILE__ ) );
			wp_enqueue_script( 'tf-styling', GASFramework::getURL( '../js/min/admin-styling-min.js', __FILE__ ) );
			wp_enqueue_style( 'tf-admin-styles', GASFramework::getURL( '../css/admin-styles.css', __FILE__ ) );
		}
	}


	/**
	 * Gets all the admin page options (not meta & customizer) and loads them from the database into
	 * a class variable. This is needed because all our admin page options are contained in a single entry.
	 *
	 * @since 1.9
	 *
	 * @return array All admin options currently in the instance
	 */
	protected function getInternalAdminOptions() {

    // Reload options if blog has been switched
		if ( empty( $this->adminOptions ) || get_current_blog_id() !== $this->blogId ) {
			$this->adminOptions = array();
		}

		if ( ! empty( $this->adminOptions ) ) {
			return $this->adminOptions;
		}

		// Check if we have options saved already.
		$currentOptions = get_option( $this->optionNamespace . '_options' );

		// First time run, this action hook can be used to trigger something.
		if ( false === $currentOptions ) {
			do_action( 'tf_init_no_options_' . $this->optionNamespace );
		}

		// Put all the available options in our global variable for future checking.
		if ( ! empty( $currentOptions ) && ! count( $this->adminOptions ) ) {
			$this->adminOptions = unserialize( $currentOptions );
		}

		if ( empty( $this->adminOptions ) ) {
			$this->adminOptions = array();
		}

		return $this->adminOptions;
	}


	/**
	 * Gets the admin page option that's loaded into the instance, used by the option class
	 *
	 * @since 1.9
	 *
	 * @param string $optionName The ID of the option (not namespaced).
	 * @param mixed  $defaultValue The default value to return if the option isn't available yet.
	 *
	 * @return mixed The option value
	 *
	 * @see GASFrameworkOption->getValue()
	 */
	public function getInternalAdminPageOption( $optionName, $defaultValue = false ) {

		// Run this first to ensure that adminOptions carries all our admin page options.
		$this->getInternalAdminOptions();

		if ( array_key_exists( $optionName, $this->adminOptions ) ) {
			return $this->adminOptions[ $optionName ];
		} else {
			return $defaultValue;
		}
	}


	/**
	 * Sets the admin page option that's loaded into the instance, used by the option class.
	 * Doesn't perform a save, only sets the value in the class variable.
	 *
	 * @since 1.9
	 *
	 * @param string $optionName The ID of the option (not namespaced).
	 * @param mixed  $value The value to set.
	 *
	 * @return bool Always returns true
	 *
	 * @see GASFrameworkOption->setValue()
	 */
	public function setInternalAdminPageOption( $optionName, $value ) {

		// Run this first to ensure that adminOptions carries all our admin page options.
		$this->getInternalAdminOptions();

		$this->adminOptions[ $optionName ] = $value;
		return true;
	}


	/**
	 * Saves all the admin (not meta & customizer) options which are currently loaded into this instance
	 *
	 * @since 1.0
	 *
	 * @return array All admin options currently in the instance
	 */
	public function saveInternalAdminPageOptions() {

		// Run this first to ensure that adminOptions carries all our admin page options.
		$this->getInternalAdminOptions();

		update_option( $this->optionNamespace . '_options', serialize( $this->adminOptions ) );
		do_action( 'tf_save_options_' . $this->optionNamespace );
		return $this->adminOptions;
	}


	/**
	 * Create a admin page
	 *
	 * @deprecated 1.9 Use createContainer() with 'type' => 'admin-page' or createAdminPage() instead.
	 * @since 1.0
	 *
	 * @param array $settings The arguments for creating the admin page.
	 *
	 * @return GASFrameworkAdminPage The created admin page
	 */
	public function createAdminPanel( $settings ) {
		// _deprecated_function( __FUNCTION__, '1.9', 'createAdminPage' );
		return $this->createAdminPage( $settings );
	}


	/**
	 *	Create a sample content only.
 	 * Use createSampleContent() with 'type' => 'sample-panel' or createSamplePanel() instead.
	 *
	 * @since 1.11
   *
 	 * @param array $settings The arguments for creating the sample conent page.
 	 *
	 *	@return GASFrameworkAdminPage The created sample coennt page.
	 */
	public function createSampleContentPage( $settings ) {
		$settings['type'] = 'sample-panel';
		return $this->createContainer( $settings );
	}


	/**
	 * Create a admin page
	 *
	 * @since 1.0
	 *
	 * @param array $settings The arguments for creating the admin page.
	 *
	 * @return GASFrameworkAdminPage The created admin page
	 */
	public function createAdminPage( $settings ) {
		$settings['type'] = 'admin-page';
		$container = $this->createContainer( $settings );
		do_action( 'tf_admin_panel_created_' . $this->optionNamespace, $container );
		return $container;
	}


	/**
	 * Create a meta box
	 *
	 * @since 1.0
	 *
	 * @param array $settings The arguments for creating the meta box.
	 *
	 * @return GASFrameworkMetaBox The created meta box
	 */
	public function createMetaBox( $settings ) {
		$settings['type'] = 'meta-box';
		return $this->createContainer( $settings );
	}


	/**
	 * Create a customizer section
	 *
	 * @deprecated 1.9 Use createContainer() with 'type' => 'customizer' or createCustomizer instead.
	 * @since 1.0
	 *
	 * @param array $settings The arguments for creating a customizer section.
	 *
	 * @return GASFrameworkCustomizer The created section
	 */
	public function createThemeCustomizerSection( $settings ) {
		// _deprecated_function( __FUNCTION__, '1.9', 'createContainer' );
		return $this->createCustomizer( $settings );
	}


	/**
	 * Create a customizer section
	 *
	 * @since 1.9
	 *
	 * @param array $settings The arguments for creating a customizer section.
	 *
	 * @return GASFrameworkCustomizer The created section
	 */
	public function createCustomizer( $settings ) {
		$settings['type'] = 'customizer';
		$container = $this->createContainer( $settings );
		do_action( 'tf_theme_customizer_created_' . $this->optionNamespace, $container );
		return $container;
	}


	/**
	 * Creates a container (e.g. admin page, meta box, customizer section) depending
	 * on the `type` parameter given in $settings
	 *
	 * @since 1.9
	 *
	 * @param array $settings The arguments for creating the container.
	 *
	 * @return GASFrameworkCustomizer|GASFrameworkAdminPage|GASFrameworkMetaBox The created container
	 */
	public function createContainer( $settings ) {
		if ( empty( $settings['type'] ) ) {
			// translators: %1$s is the name of the component or function, %2$s is the missing parameter.
			$x_content = __( '%1$s needs a %2$s parameter.',  'awesome-support'  );

			self::displayFrameworkError( sprintf( $x_content, '<code>' . __FUNCTION__ . '</code>', '<code>type</code>' ) );
			return;
		}

		$type = strtolower( $settings['type'] );
		$class = 'GASFramework' . str_replace( ' ', '', ucfirst( str_replace( '-', ' ', $settings['type'] ) ) );
		$action = str_replace( '-', '_', $type );
		$container = false;

		if ( ! class_exists( $class ) ) {

			// translators: %s is a type of container.
			$x_content = __( 'Container of type %s, does not exist.',  'awesome-support'  );

			self::displayFrameworkError( sprintf( $x_content, '<code>' . $settings['type'] . '</code>' ) );
			return;
		}

		// Create the container object.
		$container = new $class( $settings, $this );
		if ( empty( $this->mainContainers[ $type ] ) ) {
			$this->mainContainers[ $type ] = array();
		}

		$this->mainContainers[ $type ][] = $container;

		do_action( 'tf_' . $action . '_created_' . $this->optionNamespace, $container );

		return $container;
	}


	/**
	 * A function available ONLY to CHILD themes to stop the creation of options
	 * created by the PARENT theme.
	 *
	 * @since 1.2.1
	 * @access public
	 *
	 * @param string $optionName The id of the option to remove / stop from being created.
	 *
	 * @return void
	 */
	public function removeOption( $optionName ) {
		$this->optionsToRemove[] = $optionName;
	}


	/**
	 * Hook to the tf_create_option_continue filter, to check whether or not to continue
	 * adding an option (if the option id was used in $gas->removeOption).
	 *
	 * @since 1.2.1
	 * @access public
	 *
	 * @param boolean $continueCreating If true, the option will be created.
	 * @param array   $optionSettings The settings for the option to be created.
	 *
	 * @return boolean If true, continue with creating the option. False to stop it..
	 */
	public function removeChildThemeOptions( $continueCreating, $optionSettings ) {
		if ( ! count( $this->optionsToRemove ) ) {
			return $continueCreating;
		}
		if ( empty( $optionSettings['id'] ) ) {
			return $continueCreating;
		}
		if ( in_array( $optionSettings['id'], $this->optionsToRemove ) ) {
			return false;
		}
		return $continueCreating;
	}


	/**
	 * Get an option
	 *
	 * @since 1.0
	 *
	 * @param string $optionName The name of the option.
	 * @param int    $postID The post ID if this is a meta option.
	 *
	 * @return mixed The option value
	 */
	public function getOption( $optionName, $postID = null ) {
		$value = false;

		// Get the option value.
		if ( array_key_exists( $optionName, $this->optionsUsed ) ) {
			$option = $this->optionsUsed[ $optionName ];
			$value = $option->getValue( $postID );
		}

		return apply_filters( 'tf_get_option_' . $this->optionNamespace, $value, $optionName, $postID );
	}


	/**
	 * Gets a set of options. Pass an associative array containing the option names as keys and
	 * the values you want to be retained if the option names are not implemented.
	 *
	 * @since 1.9
	 *
	 * @param array $optionArray An associative array containing option names as keys.
	 * @param int   $postID The post ID if this is a meta option.
	 *
	 * @return array An array containing the values saved.
	 *
	 * @see $this->getOption()
	 */
	public function getOptions( $optionArray, $postID = null ) {
		foreach ( $optionArray as $optionName => $originalValue ) {
			if ( array_key_exists( $optionName, $this->optionsUsed ) ) {
				$optionArray[ $optionName ] = $this->getOption( $optionName, $postID );
			}
		}
		return apply_filters( 'tf_get_options_' . $this->optionNamespace, $optionArray, $postID );
	}


	/**
	 * Sets an option
	 *
	 * @since 1.0
	 *
	 * @param string $optionName The name of the option to save.
	 * @param mixed  $value The value of the option.
	 * @param int    $postID The ID of the parent post if this is a meta box option.
	 *
	 * @return boolean Always returns true
	 */
	public function setOption( $optionName, $value, $postID = null ) {

		// Get the option value.
		if ( array_key_exists( $optionName, $this->optionsUsed ) ) {
			$option = $this->optionsUsed[ $optionName ];
			$option->setValue( $value, $postID );
		}

		do_action( 'tf_set_option_' . $this->optionNamespace, $optionName, $value, $postID );

		return true;
	}


	/**
	 * Deletes ALL the options for the namespace. Even deletes all meta found in all posts.
	 * Mainly used for unit tests
	 *
	 * @since 1.9
	 *
	 * @return void
	 */
	public function deleteAllOptions() {

		// Delete all admin options.
		delete_option( $this->optionNamespace . '_options' );
		$this->adminOptions = array();

		// Delete all meta options.
		global $wpdb;
		$allPosts = $wpdb->get_results( 'SELECT ID FROM ' . $wpdb->posts, ARRAY_A );
		if ( ! empty( $allPosts ) ) {
			foreach ( $allPosts as $row ) {
				$allMeta = get_post_meta( $row['ID'] );

				// Only remove meta data that the framework created.
				foreach ( $allMeta as $metaName => $dummy ) {
					if ( stripos( $metaName, $this->optionNamespace . '_' ) === 0 ) {
						delete_post_meta( $row['ID'], $metaName );
					}
				}
			}
		}

		// Delete all theme mods.
		$allThemeMods = get_theme_mods();
		if ( ! empty( $allThemeMods ) && is_array( $allThemeMods ) ) {
			foreach ( $allThemeMods as $optionName => $dummy ) {

				// Only remove theme mods that the framework created.
				if ( stripos( $optionName, $this->optionNamespace . '_' ) === 0 ) {
					remove_theme_mod( $optionName );
				}
			}
		}
	}


	/**
	 * Generates style rules which can use options as their values
	 *
	 * @since 1.0
	 *
	 * @param string $CSSString The styles to render.
	 *
	 * @return void
	 */
	public function createCSS( $CSSString ) {
		$this->cssInstance->addCSS( $CSSString );
	}


	/**
	 * Displays an error notice
	 *
	 * @since 1.0
	 *
	 * @param string       $message The error message to display.
	 * @param array|object $errorObject The object to dump inside the error message.
	 *
	 * @return void
	 */
	public static function displayFrameworkError( $message, $errorObject = null ) {
		// Clean up the debug object for display. e.g. If this is a setting, we can have lots of blank values.
		if ( is_array( $errorObject ) ) {
			foreach ( $errorObject as $key => $val ) {
				if ( '' === $val ) {
					unset( $errorObject[ $key ] );
				}
			}
		}

		// Display an error message.
		?>
		<div style='margin: 20px; text-align: center;'><strong><?php echo wp_kses_post(GASF_NAME) ?> Error:</strong>
			<?php echo wp_kses_post($message) ?>
			<?php
			if ( ! empty( $errorObject ) ) :
				?>
				<pre><code style="display: inline-block; padding: 10px"><?php echo esc_html(print_r( $errorObject, true )) ?></code></pre>
				<?php
			endif;
			?>
		</div>
		<?php
	}


	/**
	 * Acts the same way as plugins_url( 'script', __FILE__ ) but returns then correct url
	 * when called from inside a theme.
	 *
	 * @since 1.1.2
	 *
	 * @param string $script the script to get the url to, relative to $file.
	 * @param string $file the current file, should be __FILE__.
	 *
	 * @return string the url to $script
	 */
	public static function getURL( $script, $file ) {
		$parentTheme = trailingslashit( get_template_directory() );
		$childTheme = trailingslashit( get_stylesheet_directory() );
		$plugin = trailingslashit( dirname( $file ) );

		// Windows sometimes mixes up forward and back slashes, ensure forward slash for correct URL output.
		$parentTheme = str_replace( '\\', '/', $parentTheme );
		$childTheme = str_replace( '\\', '/', $childTheme );
		$file = str_replace( '\\', '/', $file );

		$url = '';

		// Framework is in a parent theme.
		if ( stripos( $file, $parentTheme ) !== false ) {
			$dir = trailingslashit( dirname( str_replace( $parentTheme, '', $file ) ) );
			if ( './' == $dir ) {
				$dir = '';
			}
			$url = trailingslashit( get_template_directory_uri() ) . $dir . $script;

		} else if ( stripos( $file, $childTheme ) !== false ) {
			// Framework is in a child theme.
			$dir = trailingslashit( dirname( str_replace( $childTheme, '', $file ) ) );
			if ( './' == $dir ) {
				$dir = '';
			}
			$url = trailingslashit( get_stylesheet_directory_uri() ) . $dir . $script;

		} else {
			// Framework is a or in a plugin.
			$url = plugins_url( $script, $file );
		}

		// Replace /foo/../ with '/'.
		$url = preg_replace( '/\/(?!\.\.)[^\/]+\/\.\.\//', '/', $url );

		return $url;
	}


	/**
	 * Sets a value in the $setting class variable
	 *
	 * @since 1.6
	 *
	 * @param string $setting The name of the setting.
	 * @param string $value The value to set.
	 *
	 * @return void
	 */
	public function set( $setting, $value ) {
		$oldValue = $this->settings[ $setting ];
		$this->settings[ $setting ] = $value;

		do_action( 'tf_setting_' . $setting . '_changed_' . $this->optionNamespace, $value, $oldValue );
	}


	/**
	 * Gets the CSS generated
	 *
	 * @since 1.6
	 *
	 * @return string The generated CSS
	 */
	public function generateCSS() {
		return $this->cssInstance->generateCSS();
	}



	/**
	 * Adds a 'tf_save_option_{namespace}_{optionID}' filter to all Customizer options
	 * which are just about to be saved
	 *
	 * This uses the `pre_update_option` filter to check all the options being saved if it's
	 * a theme_mod option. It further checks whether these are GAS customizer options,
	 * then attaches the new hook into those.
	 *
	 * @since 1.8
	 *
	 * @param mixed  $value The value to be saved in the options.
	 * @param string $optionName The option name.
	 * @param mixed  $oldValue The previously stored value.
	 *
	 * @return mixed The modified value to save
	 *
	 * @see pre_update_option filter
	 */
	public function addCustomizerSaveFilter( $value, $optionName, $oldValue ) {

		$theme = get_option( 'stylesheet' );

		// Intercept theme mods only.
		if ( strpos( $optionName, 'theme_mods_' . $theme ) !== 0 ) {
			return $value;
		}

		// We expect theme mods to be an array.
		if ( ! is_array( $value ) ) {
			return $value;
		}

		// Checks whether a Gas customizer is in place.
		$customizerUsed = false;

		// Go through all our customizer options and filter them for saving.
		$optionIDs = array();
		if ( ! empty( $this->mainContainers['customizer'] ) ) {
			foreach ( $this->mainContainers['customizer'] as $customizer ) {
				foreach ( $customizer->options as $option ) {
					if ( ! empty( $option->settings['id'] ) ) {
						$optionID = $option->settings['id'];
						$themeModName = $this->optionNamespace . '_' . $option->settings['id'];

						if ( ! array_key_exists( $themeModName, $value ) ) {
							continue;
						}

						$customizerUsed = true;

						// Try and unserialize if possible.
						$tempValue = $value[ $themeModName ];
						if ( is_serialized( $tempValue ) ) {
							$tempValue = unserialize( $tempValue );
						}

						// Hook 'tf_save_option_{namespace}'.
						$newValue = apply_filters( 'tf_save_option_' . $this->optionNamespace, $tempValue, $option->settings['id'] );

						// Hook 'tf_save_option_{namespace}_{optionID}'.
						$newValue = apply_filters( 'tf_save_option_' . $themeModName, $tempValue );

						// We mainly check for equality here so that we won't have to serialize IF the value wasn't touched anyway.
						if ( $newValue != $tempValue ) {
							if ( is_array( $newValue ) ) {
								$newValue = serialize( $newValue );
							}

							$value[ $themeModName ] = $newValue;
						}
					}
				}
			}
		}

		if ( $customizerUsed ) {
			/** This action is documented in class-admin-page.php */
			$namespace = $this->optionNamespace;
			do_action( "tf_pre_save_options_{$namespace}", $this->mainContainers['customizer'] );
		}

		return $value;
	}
}
