<?php
/**
 * Controller
 * @package Simple Lightbox
 * @author Archetyped
 */
class SLB_Lightbox extends SLB_Base {

	/*-** Properties **-*/

	protected $model = true;

	/**
	 * Fields
	 * @var SLB_Fields
	 */
	public $fields = null;

	/**
	 * Themes collection
	 * @var SLB_Themes
	 */
	public $themes = null;

	/**
	 * Content types
	 * @var SLB_Content_Handlers
	 */
	public $handlers = null;

	/**
	 * Template tags
	 * @var SLB_Template_Tags
	 */
	public $template_tags = null;

	/**
	 * Media item template.
	 *
	 * @var array {
	 *     Media item properties.
	 *
	 *     @type int $id WP post ID. Default null.
	 *     @type string $type Item type. Default null.
	 *     @type string $source Source URI.
	 *     @type bool $internal Internal resource. Default false.
	 *     @type string $title Item title.
	 *     @type string $caption Item caption.
	 *     @type string $description Item description.
	 *     @type array $media {
	 *         Media properties (indexed by size name).
	 *         Original size = 'full'.
	 *
	 *         @type string $file File URI.
	 *         @type int $width File width in pixels.
	 *         @type int $height File height in pixels.
	 *     }
	 *     @type array $meta {
	 *         Item metadata.
	 *     }
	 * }
	 */
	private $media_item_template = [
		'id'       => null,
		'type'     => null,
		'internal' => false,
		'source'   => '',
	];

	/**
	 * Processed media items for output to client.
	 *
	 * @var object[string] {
	 *     Media item properties.
	 *
	 *     @index string Unique ID (system-generated).
	 *
	 *     @see $media_item_template.
	 * }
	 */
	private $media_items = [];

	/**
	 * Collection of unprocessed media items.
	 *
	 * @var array {
	 *     @type object[string] $props {
	 *         Media item properties.
	 *
	 *         @index string Unique ID (system-generated).
	 *
	 *         @see $media_item_template
	 *     }
	 *     @type string[string] $uri {
	 *         Cached URIs.
	 *
	 *         @index string URI.
	 *
	 *         @type string Item ID (points to item in `props` array)
	 *     }
	 * }
	 */
	private $media_items_raw = [
		'props' => [],
		'uri'   => [],
	];

	/**
	 * Manage excluded content
	 * @var object
	 */
	private $exclude = null;

	private $groups = array(
		'auto'   => 0,
		'manual' => array(),
	);

	/**
	 * Validated URIs
	 * Caches validation of parsed URIs
	 * > Key: URI
	 * > Value: (bool) TRUE if valid
	 * @var array
	 */
	private $validated_uris = array();

	/* Widget properties */

	/**
	 * Used to track if widget is currently being processed or not
	 * Set to Widget ID currently being processed
	 * @var bool|string
	 */
	private $widget_processing = false;

	/**
	 * Parameters for widget being processed
	 * @param array
	 */
	private $widget_processing_params = null;

	/**
	 * Manage nested widget processing
	 * Used to avoid premature widget output
	 * @var int
	 */
	private $widget_processing_level = 0;

	/**
	 * Constructor
	 */
	public function __construct() {
		parent::__construct();
		// Init instances
		$this->fields = new SLB_Fields();
		$this->themes = new SLB_Themes( $this );
		if ( ! is_admin() ) {
			$this->template_tags = new SLB_Template_Tags( $this );
		}
	}

	/* Init */

	public function _init() {
		parent::_init();
		$this->util->do_action( 'init' );
	}

	/**
	 * Declare client files (scripts, styles)
	 * @uses parent::_client_files()
	 * @return void
	 */
	protected function _client_files( $files = null ) {
		$js_path  = 'client/js/';
		$js_path .= ( SLB_DEV ) ? 'dev' : 'prod';
		$files    = array(
			'scripts' => array(
				'core' => array(
					'file'      => "$js_path/lib.core.js",
					'deps'      => 'jquery',
					'enqueue'   => false,
					'in_footer' => true,
				),
				'view' => array(
					'file'      => "$js_path/lib.view.js",
					'deps'      => array( '[core]' ),
					'context'   => array( array( 'public', $this->m( 'is_request_valid' ) ) ),
					'in_footer' => true,
				),
			),
			'styles'  => array(
				'core' => array(
					'file'    => 'client/css/app.css',
					'context' => array( 'public' ),
				),
			),
		);
		parent::_client_files( $files );
	}

	/**
	 * Register hooks
	 * @uses parent::_hooks()
	 */
	protected function _hooks() {
		parent::_hooks();

		/* Admin */
		add_action( 'admin_menu', $this->m( 'admin_menus' ) );

		/* Init */
		add_action( 'wp', $this->m( '_hooks_init' ) );
	}

	/**
	 * Initialize hooks.
	 *
	 * @return void
	 */
	public function _hooks_init() {
		if ( ! $this->is_enabled() ) {
			return;
		}
		// Callback for hooks that need to have method added to end of stack.
		$cb_hooks_add_last = $this->m( 'hooks_add_last' );

		// Init lightbox
		add_action( 'wp_footer', $this->m( 'client_footer' ) );
		$this->util->add_action( 'footer_script', $this->m( 'client_init' ), 1 );
		$this->util->add_filter( 'footer_script', $this->m( 'client_script_media' ), 2 );
		// Link activation
		add_filter( 'the_content', $cb_hooks_add_last );
		add_filter( 'get_post_galleries', $cb_hooks_add_last );
		$this->util->add_filter( 'post_process_links', $this->m( 'activate_groups' ), 11 );
		$this->util->add_filter( 'validate_uri_regex', $this->m( 'validate_uri_regex_default' ), 1 );
		//  Content exclusion
		$this->util->add_filter( 'pre_process_links', $this->m( 'exclude_content' ) );
		$this->util->add_filter( 'pre_exclude_content', $this->m( 'exclude_shortcodes' ) );
		$this->util->add_filter( 'post_process_links', $this->m( 'restore_excluded_content' ) );

		// Grouping
		if ( $this->options->get_bool( 'group_post' ) ) {
			$this->util->add_filter( 'get_group_id', $this->m( 'post_group_id' ), 1 );
		}

		// Shortcode grouping
		if ( $this->options->get_bool( 'group_gallery' ) ) {
			add_filter( 'the_content', $this->m( 'group_shortcodes' ), 1 );
		}

		// Widgets
		if ( $this->options->get_bool( 'enabled_widget' ) ) {
			add_action( 'dynamic_sidebar_before', $this->m( 'widget_process_nested' ) );
			add_action( 'dynamic_sidebar', $cb_hooks_add_last );
			add_filter( 'dynamic_sidebar_params', $this->m( 'widget_process_inter' ), 1 );
			add_action( 'dynamic_sidebar_after', $cb_hooks_add_last );
			add_action( 'dynamic_sidebar_after', $cb_hooks_add_last );
		} else {
			add_action( 'dynamic_sidebar_before', $this->m( 'widget_block_start' ) );
			add_action( 'dynamic_sidebar_after', $this->m( 'widget_block_finish' ) );
		}

		// Menus
		if ( $this->options->get_bool( 'enabled_menu' ) ) {
			add_filter( 'wp_nav_menu', $cb_hooks_add_last );
		}
	}

	/**
	 * Adds hook(s) to end of filter/action stack.
	 *
	 * @param string $data Optional. Data being filtered.
	 * @return string Filtered content.
	 */
	public function hooks_add_last( $data = null ) {
		global $wp_filter;

		$tag = current_filter();

		// Stop processing on invalid hook.
		if ( empty( $tag ) || ! is_string( $tag ) ) {
			return $data;
		}

		// Get lowest priority for filter.
		$max_priority = max( array_keys( $wp_filter[ $tag ]->callbacks ) ) + 123;

		switch ( $tag ) {
			case 'the_content':
				add_filter( $tag, $this->m( 'activate_links' ), $max_priority );
				break;
			case 'get_post_galleries':
				add_filter( $tag, $this->m( 'activate_galleries' ), $max_priority );
				break;
			case 'dynamic_sidebar':
				add_action( $tag, $this->m( 'widget_process_start' ), $max_priority );
				break;
			case 'dynamic_sidebar_after':
				add_action( $tag, $this->m( 'widget_process_finish' ), $max_priority );
				add_action( $tag, $this->m( 'widget_process_nested_finish' ), $max_priority );
				break;
			case 'wp_nav_menu':
				add_filter( $tag, $this->m( 'menu_process' ), $max_priority, 2 );
				break;
		}

		// Remove init hook.
		remove_filter( $tag, $this->m( __FUNCTION__ ) );

		// Return content (for filters).
		return $data;
	}

	/**
	 * Add post ID to link group ID
	 * @uses `SLB::get_group_id` filter
	 * @param array $group_segments Group ID segments
	 * @return array Modified group ID segments
	 */
	public function post_group_id( $group_segments ) {
		if ( in_the_loop() ) {
			// Prepend post ID to group ID
			$post = get_post();
			if ( $post ) {
				array_unshift( $group_segments, $post->ID );
			}
		}
		return $group_segments;
	}

	/**
	 * Init options
	 */
	protected function _options() {
		// Setup options
		$opts = array(
			'groups' => array(
				'activation' => array(
					'title'    => __( 'Activation', 'simple-lightbox' ),
					'priority' => 10,
				),
				'grouping'   => array(
					'title'    => __( 'Grouping', 'simple-lightbox' ),
					'priority' => 20,
				),
				'ui'         => array(
					'title'    => __( 'UI', 'simple-lightbox' ),
					'priority' => 30,
				),
				'labels'     => array(
					'title'    => __( 'Labels', 'simple-lightbox' ),
					'priority' => 40,
				),
			),
			'items'  => array(
				'enabled'             => array(
					'title'   => __( 'Enable Lightbox Functionality', 'simple-lightbox' ),
					'default' => true,
					'group'   => array( 'activation', 10 ),
				),
				'enabled_home'        => array(
					'title'   => __( 'Enable on Home page', 'simple-lightbox' ),
					'default' => true,
					'group'   => array( 'activation', 20 ),
				),
				'enabled_post'        => array(
					'title'   => __( 'Enable on Single Posts', 'simple-lightbox' ),
					'default' => true,
					'group'   => array( 'activation', 30 ),
				),
				'enabled_page'        => array(
					'title'   => __( 'Enable on Pages', 'simple-lightbox' ),
					'default' => true,
					'group'   => array( 'activation', 40 ),
				),
				'enabled_archive'     => array(
					'title'   => __( 'Enable on Archive Pages (tags, categories, etc.)', 'simple-lightbox' ),
					'default' => true,
					'group'   => array( 'activation', 50 ),
				),
				'enabled_widget'      => array(
					'title'   => __( 'Enable for Widgets', 'simple-lightbox' ),
					'default' => false,
					'group'   => array( 'activation', 60 ),
				),
				'enabled_menu'        => array(
					'title'   => __( 'Enable for Menus', 'simple-lightbox' ),
					'default' => false,
					'group'   => array( 'activation', 60 ),
				),
				'group_links'         => array(
					'title'   => __( 'Group items (for displaying as a slideshow)', 'simple-lightbox' ),
					'default' => true,
					'group'   => array( 'grouping', 10 ),
				),
				'group_post'          => array(
					'title'   => __( 'Group items by Post (e.g. on pages with multiple posts)', 'simple-lightbox' ),
					'default' => true,
					'group'   => array( 'grouping', 20 ),
				),
				'group_gallery'       => array(
					'title'   => __( 'Group gallery items separately', 'simple-lightbox' ),
					'default' => false,
					'group'   => array( 'grouping', 30 ),
				),
				'group_widget'        => array(
					'title'   => __( 'Group widget items separately', 'simple-lightbox' ),
					'default' => false,
					'group'   => array( 'grouping', 40 ),
				),
				'group_menu'          => array(
					'title'   => __( 'Group menu items separately', 'simple-lightbox' ),
					'default' => false,
					'group'   => array( 'grouping', 50 ),
				),
				'ui_autofit'          => array(
					'title'     => __( 'Resize lightbox to fit in window', 'simple-lightbox' ),
					'default'   => true,
					'group'     => array( 'ui', 10 ),
					'in_client' => true,
				),
				'ui_animate'          => array(
					'title'     => __( 'Enable animations', 'simple-lightbox' ),
					'default'   => true,
					'group'     => array( 'ui', 20 ),
					'in_client' => true,
				),
				'slideshow_autostart' => array(
					'title'     => __( 'Start Slideshow Automatically', 'simple-lightbox' ),
					'default'   => true,
					'group'     => array( 'ui', 30 ),
					'in_client' => true,
				),
				'slideshow_duration'  => array(
					'title'     => __( 'Slide Duration (Seconds)', 'simple-lightbox' ),
					'default'   => '6',
					'attr'      => array(
						'size'      => 3,
						'maxlength' => 3,
					),
					'group'     => array( 'ui', 40 ),
					'in_client' => true,
				),
				'group_loop'          => array(
					'title'     => __( 'Loop through items', 'simple-lightbox' ),
					'default'   => true,
					'group'     => array( 'ui', 50 ),
					'in_client' => true,
				),
				'ui_overlay_opacity'  => array(
					'title'     => __( 'Overlay Opacity (0 - 1)', 'simple-lightbox' ),
					'default'   => '0.8',
					'attr'      => array(
						'size'      => 3,
						'maxlength' => 3,
					),
					'group'     => array( 'ui', 60 ),
					'in_client' => true,
				),
				'ui_title_default'    => array(
					'title'     => __( 'Enable default title', 'simple-lightbox' ),
					'default'   => false,
					'group'     => array( 'ui', 70 ),
					'in_client' => true,
				),
				'txt_loading'         => array(
					'title'   => __( 'Loading indicator', 'simple-lightbox' ),
					'default' => 'Loading',
					'group'   => array( 'labels', 20 ),
				),
				'txt_close'           => array(
					'title'   => __( 'Close button', 'simple-lightbox' ),
					'default' => 'Close',
					'group'   => array( 'labels', 10 ),
				),
				'txt_nav_next'        => array(
					'title'   => __( 'Next Item button', 'simple-lightbox' ),
					'default' => 'Next',
					'group'   => array( 'labels', 30 ),
				),
				'txt_nav_prev'        => array(
					'title'   => __( 'Previous Item button', 'simple-lightbox' ),
					'default' => 'Previous',
					'group'   => array( 'labels', 40 ),
				),
				'txt_slideshow_start' => array(
					'title'   => __( 'Start Slideshow button', 'simple-lightbox' ),
					'default' => 'Start slideshow',
					'group'   => array( 'labels', 50 ),
				),
				'txt_slideshow_stop'  => array(
					'title'   => __( 'Stop Slideshow button', 'simple-lightbox' ),
					'default' => 'Stop slideshow',
					'group'   => array( 'labels', 60 ),
				),
				'txt_group_status'    => array(
					'title'   => __( 'Slideshow status format', 'simple-lightbox' ),
					'default' => 'Item %current% of %total%',
					'group'   => array( 'labels', 70 ),
				),
			),
			'legacy' => array(
				'header_activation'       => null,
				'header_enabled'          => null,
				'header_strings'          => null,
				'header_ui'               => null,
				'activate_attachments'    => null,
				'validate_links'          => null,
				'enabled_compat'          => null,
				'enabled_single'          => array( 'enabled_post', 'enabled_page' ),
				'enabled_caption'         => null,
				'enabled_desc'            => null,
				'ui_enabled_caption'      => null,
				'ui_caption_src'          => null,
				'ui_enabled_desc'         => null,
				'caption_src'             => null,
				'animate'                 => 'ui_animate',
				'overlay_opacity'         => 'ui_overlay_opacity',
				'loop'                    => 'group_loop',
				'autostart'               => 'slideshow_autostart',
				'duration'                => 'slideshow_duration',
				'txt_numDisplayPrefix'    => null,
				'txt_numDisplaySeparator' => null,
				'txt_closeLink'           => 'txt_link_close',
				'txt_nextLink'            => 'txt_link_next',
				'txt_prevLink'            => 'txt_link_prev',
				'txt_startSlideshow'      => 'txt_slideshow_start',
				'txt_stopSlideshow'       => 'txt_slideshow_stop',
				'txt_loadingMsg'          => 'txt_loading',
				'txt_link_next'           => 'txt_nav_next',
				'txt_link_prev'           => 'txt_nav_prev',
				'txt_link_close'          => 'txt_close',
			),
		);

		parent::_set_options( $opts );
	}

	/* Methods */

	/*-** Admin **-*/

	/**
	 * Add admin menus
	 * @uses this->admin->add_theme_page
	 */
	function admin_menus() {
		// Build options page
		$lbls_opts = array(
			'menu'          => __( 'Lightbox', 'simple-lightbox' ),
			'header'        => __( 'Lightbox Settings', 'simple-lightbox' ),
			'plugin_action' => __( 'Settings', 'simple-lightbox' ),
		);
		$pg_opts   = $this->admin->add_theme_page( 'options', $lbls_opts )
			->require_form()
			->add_content( 'options', 'Options', $this->options );

		// Add Support information
		$support = $this->util->get_plugin_info( 'SupportURI' );
		if ( ! empty( $support ) ) {
			$pg_opts->add_content( 'support', __( 'Feedback & Support', 'simple-lightbox' ), $this->m( 'theme_page_callback_support' ), 'secondary' );
		}

		// Add Actions
		$lbls_reset = array(
			'title'   => __( 'Reset', 'simple-lightbox' ),
			'confirm' => __( 'Are you sure you want to reset Simple Lightbox\'s settings?', 'simple-lightbox' ),
			'success' => __( 'Settings have been reset', 'simple-lightbox' ),
			'failure' => __( 'Settings were not reset', 'simple-lightbox' ),
		);
		$this->admin->add_action( 'reset', $lbls_reset, $this->options );
	}

	/**
	 * Support information
	 */
	public function theme_page_callback_support() {
		// Description
		$desc = __( '<p>Simple Lightbox thrives on your feedback!</p><p>Click the button below to <strong>get help</strong>, <strong>request a feature</strong>, or <strong>provide some feedback</strong>!</p>', 'simple-lightbox' );
		echo $desc;
		// Link
		$lnk_uri = $this->util->get_plugin_info( 'SupportURI' );
		$lnk_txt = __( 'Get Support &amp; Provide Feedback', 'simple-lightbox' );
		echo $this->util->build_html_link(
			$lnk_uri,
			$lnk_txt,
			array(
				'target' => '_blank',
				'class'  => 'button',
			)
		);
	}

	/*-** Functionality **-*/

	/**
	 * Checks whether lightbox is currently enabled/disabled
	 * @return bool TRUE if lightbox is currently enabled, FALSE otherwise
	 */
	function is_enabled() {
		static $ret = null;
		if ( is_null( $ret ) ) {
			$ret = ( ! is_admin() && $this->options->get_bool( 'enabled' ) && ! is_feed() ) ? true : false;
			if ( $ret ) {
				$opt = '';
				// Determine option to check
				if ( is_home() || is_front_page() ) {
					$opt = 'home';
				} elseif ( is_singular() ) {
					$opt = ( is_page() ) ? 'page' : 'post';
				} elseif ( is_archive() || is_search() ) {
					$opt = 'archive';
				}
				// Check sub-option
				if ( ! empty( $opt ) ) {
					// Prefix option name.
					$opt = 'enabled_' . $opt;
					if ( $this->options->has( $opt ) ) {
						$ret = $this->options->get_bool( $opt );
					}
				}
			}
		}
		// Filter return value
		if ( ! is_admin() ) {
			$ret = $this->util->apply_filters( 'is_enabled', $ret );
		}
		// Return value (force boolean)
		return ! ! $ret;
	}

	/**
	 * Make sure content is valid for processing/activation
	 *
	 * @param string $content Content to validate
	 * @return bool TRUE if content is valid (FALSE otherwise)
	 */
	protected function is_content_valid( $content ) {
		// Invalid hooks
		if ( doing_filter( 'get_the_excerpt' ) ) {
			return false;
		}

		// Non-string value
		if ( ! is_string( $content ) ) {
			return false;
		}

		// Empty string
		$content = trim( $content );
		if ( empty( $content ) ) {
			return false;
		}

		// Content is valid
		return $this->util->apply_filters( 'is_content_valid', true, $content );
	}

	/**
	 * Activates galleries extracted from post
	 * @see get_post_galleries()
	 * @param array $galleries A list of galleries in post
	 * @return A list of galleries with links activated
	 */
	function activate_galleries( $galleries ) {
		// Validate
		if ( empty( $galleries ) ) {
			return $galleries;
		}
		// Check galleries for HTML output
		$gallery = reset( $galleries );
		if ( is_array( $gallery ) ) {
			return $galleries;
		}

		// Activate galleries
		$group = ( $this->options->get_bool( 'group_gallery' ) ) ? true : null;
		foreach ( $galleries as $key => $val ) {
			if ( ! is_null( $group ) ) {
				$group = 'gallery_' . $key;
			}
			// Activate links in gallery
			$gallery = $this->process_links( $val, $group );

			// Save modified gallery
			$galleries[ $key ] = $gallery;
		}

		return $galleries;
	}

	/**
	 * Scans post content for image links and activates them
	 *
	 * Lightbox will not be activated for feeds
	 * @param string $content Content to activate
	 * @param string (optonal) $group Group ID for content
	 * @return string Post content
	 */
	public function activate_links( $content, $group = null ) {
		// Validate content
		if ( ! $this->is_content_valid( $content ) ) {
			return $content;
		}
		// Filter content before processing links
		$content = $this->util->apply_filters( 'pre_process_links', $content );

		// Process links
		$content = $this->process_links( $content, $group );

		// Filter content after processing links
		$content = $this->util->apply_filters( 'post_process_links', $content );

		return $content;
	}

	/**
	 * Process links in content
	 * @global obj $wpdb DB instance
	 * @global obj $post Current post
	 * @param string $content Text containing links
	 * @param string (optional) $group Group to add links to (Default: none)
	 * @return string Content with processed links
	 */
	protected function process_links( $content, $group = null ) {
		// Extract links
		$links = $this->get_links( $content, true );
		// Do not process content without links
		if ( empty( $links ) ) {
			return $content;
		}
		// Process links
		static $protocol   = array( 'http://', 'https://' );
		static $qv_att     = 'attachment_id';
		static $uri_origin = null;
		if ( ! is_array( $uri_origin ) ) {
			$uri_parts  = array_fill_keys( array( 'scheme', 'host', 'path' ), '' );
			$uri_origin = wp_parse_args( wp_parse_url( strtolower( home_url() ) ), $uri_parts );
		}
		static $uri_proto = null;
		if ( empty( $uri_proto ) ) {
			$uri_proto = (object) array(
				'raw'    => '',
				'source' => '',
				'parts'  => '',
			);
		}
		$uri_parts_required = array( 'host' => '' );

		// Setup group properties
		$g_props = (object) array(
			'enabled'       => $this->options->get_bool( 'group_links' ),
			'attr'          => 'group',
			'base'          => '',
			'legacy_prefix' => 'lightbox[',
			'legacy_suffix' => ']',
		);
		if ( $g_props->enabled ) {
			$g_props->base = ( is_scalar( $group ) ) ? trim( strval( $group ) ) : '';
		}

		// Initialize content handlers
		if ( ! ( $this->handlers instanceof SLB_Content_Handlers ) ) {
			$this->handlers = new SLB_Content_Handlers( $this );
		}

		// Iterate through and activate supported links

		foreach ( $links as $link ) {
			// Init vars
			$pid         = 0;
			$link_new    = $link;
			$uri         = clone $uri_proto;
			$type        = false;
			$props_extra = array();
			$key         = null;
			$internal    = false;

			// Parse link attributes
			$attrs = $this->util->parse_attribute_string( $link_new, array( 'href' => '' ) );
			// Get URI
			$uri->raw = $attrs['href'];

			// Stop processing invalid links
			if ( ! $this->validate_uri( $uri->raw )
				|| $this->has_attribute( $attrs, 'active' ) // Previously-processed.
				) {
				continue;
			}

			// Normalize URI (make absolute)
			$uri->source = WP_HTTP::make_absolute_url( $uri->raw, $uri_origin['scheme'] . '://' . $uri_origin['host'] );

			// URI cached?
			$key = $this->get_media_item_id( $uri->source );

			// Internal URI? (e.g. attachments)
			if ( ! $key ) {
				$uri->parts = array_merge( $uri_parts_required, (array) wp_parse_url( $uri->source ) );
				$internal   = ( $uri->parts['host'] === $uri_origin['host'] ) ? true : false;

				// Attachment?
				if ( $internal && is_local_attachment( $uri->source ) ) {
					$pid = url_to_postid( $uri->source );
					$src = wp_get_attachment_url( $pid );
					if ( ! ! $src ) {
						$uri->source       = $src;
						$props_extra['id'] = $pid;
						// Check cache for attachment source URI
						$key = $this->get_media_item_id( $uri->source );
					}
					unset( $src );
				}
			}

			// Determine content type
			if ( ! $key ) {
				// Get handler match
				$hdl_result = $this->handlers->match( $uri->source );
				if ( ! ! $hdl_result->handler ) {
					$type        = $hdl_result->handler->get_id();
					$props_extra = $hdl_result->props;
					// Updated source URI
					if ( isset( $props_extra['uri'] ) ) {
						$uri->source = $props_extra['uri'];
						unset( $props_extra['uri'] );
					}
				}

				// Cache valid item
				if ( ! ! $type ) {
					$key = $this->cache_media_item( $uri, $type, $internal, $props_extra );
				}
			}

			// Stop processing invalid links
			if ( ! $key ) {
				// Cache invalid URI
				$this->validated_uris[ $uri->source ] = false;
				if ( $uri->raw !== $uri->source ) {
					$this->validated_uris[ $uri->raw ] = false;
				}
				continue;
			}

			// Activate link
			$this->set_attribute( $attrs, 'active' );
			$this->set_attribute( $attrs, 'asset', $key );
			// Mark internal links
			if ( $internal ) {
				$this->set_attribute( $attrs, 'internal', $pid );
			}

			// Set group (if enabled)
			if ( $g_props->enabled ) {
				$group = array();
				// Get preset group attribute
				$g = ( $this->has_attribute( $attrs, $g_props->attr ) ) ? $this->get_attribute( $attrs, $g_props->attr ) : '';
				if ( ! empty( $g ) ) {
						$group[] = $g;
				} elseif ( ! empty( $g_props->base ) ) {
					$group[] = $g_props->base;
				}

				/**
				 * Filter group ID components
				 *
				 * @see process_links()
				 *
				 * @param array $group Components used to build group ID
				 */
				$group = $this->util->apply_filters( 'get_group_id', $group );

				// Default group
				if ( empty( $group ) || ! is_array( $group ) ) {
					$group = $this->get_prefix();
				} else {
					$group = implode( '_', $group );
				}

				// Set group attribute
				$this->set_attribute( $attrs, $g_props->attr, $group );
				unset( $g );
			}

			// Filter attributes
			$attrs = $this->util->apply_filters( 'process_link_attributes', $attrs );

			// Update link in content
			$link_new = '<a ' . $this->util->build_attribute_string( $attrs ) . '>';
			$content  = str_replace( $link, $link_new, $content );
		}

		// Handle widget content
		if ( ! ! $this->widget_processing && 'the_content' === current_filter() ) {
			$content = $this->exclude_wrap( $content );
		}

		return $content;
	}

	/**
	 * Retrieve HTML links in content
	 * @param string $content Content to get links from
	 * @param bool (optional) $unique Remove duplicates from returned links (Default: FALSE)
	 * @return array Links in content
	 */
	function get_links( $content, $unique = false ) {
		$rgx   = "/\<a\b(?:(?!\shref=|\>).)*\shref=[^\>\<]++\>/i";
		$links = [];
		preg_match_all( $rgx, $content, $links );
		$links = $links[0];
		if ( $unique ) {
			$links = array_unique( $links );
		}
		return $links;
	}

	/**
	 * Validate URI
	 * Matches specified URI against internal & external regex patterns
	 * URI is **invalid** if it matches a regex
	 *
	 * @param string $uri URI to validate
	 * @return bool TRUE if URI is valid
	 */
	protected function validate_uri( $uri ) {
		static $patterns = null;
		// Previously-validated URI
		if ( isset( $this->validated_uris[ $uri ] ) ) {
			return $this->validated_uris[ $uri ];
		}

		$valid = true;
		// Boilerplate validation
		if ( empty( $uri ) // Empty
			|| 0 === strpos( $uri, '#' ) // Anchor
			) {
			$valid = false;
		}

		// Regex matching
		if ( $valid ) {
			// Get patterns
			if ( is_null( $patterns ) ) {
				$patterns = $this->util->apply_filters( 'validate_uri_regex', array() );
			}
			// Iterate through patterns until match found
			foreach ( $patterns as $pattern ) {
				if ( 1 === preg_match( $pattern, $uri ) ) {
					$valid = false;
					break;
				}
			}
		}

		// Cache
		$this->validated_uris[ $uri ] = $valid;
		return $valid;
	}

	/**
	 * Add URI validation regex pattern
	 * @param
	 */
	public function validate_uri_regex_default( $patterns ) {
		$patterns[] = '@^https?://[^/]*(wikipedia|wikimedia)\.org/wiki/file:.*$@i';
		return $patterns;
	}

	/* Client */

	/**
	 * Checks if output should be loaded in current request
	 * @uses `is_enabled()`
	 * @uses `has_cached_media_items()`
	 * @return bool TRUE if output is being loaded into client
	 */
	public function is_request_valid() {
		return ( $this->is_enabled() && $this->has_cached_media_items() ) ? true : false;
	}

	/**
	 * Sets options/settings to initialize lightbox functionality on page load
	 * @return void
	 */
	function client_init( $client_script ) {
		// Get options
		$options = $this->options->build_client_output();

		// Load UI Strings
		$labels = $this->build_labels();
		if ( ! empty( $labels ) ) {
			$options['ui_labels'] = $labels;
		}

		// Build client output
		$client_script[] = $this->util->call_client_method( 'View.init', $options );
		return $client_script;
	}

	/**
	 * Output code in footer
	 * > Media attachment URLs
	 * @uses `_wp_attached_file` to match attachment ID to URI
	 * @uses `_wp_attachment_metadata` to retrieve attachment metadata
	 */
	function client_footer() {
		if ( ! $this->has_cached_media_items() ) {
			return false;
		}

		// Set up hooks
		add_action( 'wp_print_footer_scripts', $this->m( 'client_footer_script' ) );

		// Build client output
		$this->util->do_action( 'footer' );
	}

	/**
	 * Output client footer scripts
	 */
	function client_footer_script() {
		$client_script = $this->util->apply_filters( 'footer_script', array() );
		if ( ! empty( $client_script ) ) {
			echo $this->util->build_script_element( $client_script, 'footer', true, true );
		}
	}

	/**
	 * Add media information to client output
	 *
	 * @param array $client_script Client script commands.
	 * @return array Modified script commands.
	 * TODO Refactor
	 */
	public function client_script_media( $client_script ) {
		global $wpdb;

		// Init.
		$this->media_items = $this->get_cached_media_items();

		// Extract internal links for additional processing.
		$m_internals = [];
		foreach ( $this->media_items as $key => $p ) {
			if ( $p->internal ) {
				$m_internals[ $key ] =& $this->media_items[ $key ];
			}
		}
		// Cleanup.
		unset( $key, $p );

		// Process internal links.
		if ( ! empty( $m_internals ) ) {
			$uris_base  = [];
			$uri_prefix = wp_upload_dir();
			$uri_prefix = $this->util->normalize_path( $uri_prefix['baseurl'], true );
			foreach ( $m_internals as $key => $p ) {
				// Prepare internal links.
				// Create relative URIs for attachment data retrieval.
				if ( ! $p->id && strpos( $p->source, $uri_prefix ) === 0 ) {
					$uris_base[ str_replace( $uri_prefix, '', $p->source ) ] = $key;
				}
			}
			// Cleanup.
			unset( $key, $p );

			// Retrieve attachment IDs.
			$uris_flat = "('" . implode( "','", array_keys( $uris_base ) ) . "')";
			$q         = $wpdb->prepare( "SELECT post_id, meta_value FROM $wpdb->postmeta WHERE `meta_key` = %s AND LOWER(`meta_value`) IN $uris_flat LIMIT %d", '_wp_attached_file', count( $uris_base ) );
			$pids      = $wpdb->get_results( $q );
			// Match IDs to URIs.
			if ( $pids ) {
				foreach ( $pids as $pd ) {
					$file =& $pd->meta_value;
					if ( isset( $uris_base[ $file ] ) ) {
						$m_internals[ $uris_base[ $file ] ]->id = absint( $pd->post_id );
					}
				}
			}
			// Cleanup.
			unset( $uris_base, $uris_flat, $q, $pids, $pd, $file );
		}

		// Process items with attachment IDs.
		$pids = [];
		foreach ( $this->media_items as $key => $p ) {
			// Add post ID to query.
			if ( ! ! $p->id ) {
				// Create array for ID (support multiple URIs per ID).
				if ( ! isset( $pids[ $p->id ] ) ) {
					$pids[ $p->id ] = [];
				}
				// Add URI to ID.
				$pids[ $p->id ][] = $key;
			}
		}
		// Cleanup.
		unset( $key, $p );

		// Retrieve attachment properties.
		if ( ! empty( $pids ) ) {
			$pids_flat = array_keys( $pids );
			// Retrieve attachment post data.
			$atts = get_posts(
				array(
					'post_type' => 'attachment',
					'include'   => $pids_flat,
				)
			);

			// Process attachments.
			if ( $atts ) {
				$props_post_map = [
					'title'       => 'post_title',
					'caption'     => 'post_excerpt',
					'description' => 'post_content',
				];

				foreach ( $atts as $att ) {
					$data = [];
					// Remap post data to metadata.
					foreach ( $props_post_map as $props_post_key => $props_post_source ) {
						$data[ $props_post_key ] = $att->{$props_post_source};
					}
					// Cleanup.
					unset( $props_post_key, $props_post_source );

					// Save data to corresponding media item(s).
					if ( isset( $pids[ $att->ID ] ) ) {
						foreach ( $pids[ $att->ID ] as $key ) {
							$this->media_items[ $key ] = (object) array_merge( (array) $this->media_items[ $key ], $data );
						}
					}
				}
				// Cleanup.
				unset( $att, $data );
			}
			// Cleanup.
			unset( $atts, $atts_meta, $m, $a, $uri, $pids, $pids_flat );
		}

		// Filter media items.
		$this->media_items = $this->util->apply_filters( 'media_items', $this->media_items );

		// Build client output.
		$obj             = 'View.assets';
		$client_script[] = $this->util->extend_client_object( $obj, $this->media_items );
		return $client_script;
	}

	/*-** Media **-*/

	/**
	 * Cache media properties for later processing
	 * @uses array self::$media_items_raw Stores media items for output
	 * @param object $uri URI to cache
	 * Members
	 * > raw: Raw Link URI
	 * > source: Source URI (e.g. for attachment URIs)
	 * @param string $type Media type (image, attachment, etc.)
	 * @param bool $internal TRUE if media is internal (e.g. attachment)
	 * @param array $props (optional) Properties to store for item (Default: NULL)
	 * @return string Unique ID for cached media item
	 */
	private function cache_media_item( $uri, $type, $internal, $props = null ) {
		// Validate.
		if ( ! is_object( $uri ) || ! is_string( $type ) ) {
			return false;
		}
		// Check if URI already cached.
		$key = $this->get_media_item_id( $uri->source );
		// Cache new item.
		if ( null === $key ) {
			// Generate Unique ID.
			do {
				$key = (string) wp_rand();
			} while ( isset( $this->media_items_raw['props'][ $key ] ) );
			// Build properties object.
			$i = $this->media_item_template;
			if ( is_array( $props ) && ! empty( $props ) ) {
				$i = array_merge( $i, $props );
			}
			$i = array_merge(
				$i,
				[
					'type'     => $type,
					'source'   => $uri->source,
					'internal' => $internal,
				]
			);
			// Cache item properties.
			$this->media_items_raw['props'][ $key ] = (object) $i;
			// Cache Source URI (point to properties object).
			$this->media_items_raw['uri'][ $uri->source ] = $key;
		}
		return $key;
	}

	/**
	 * Retrieve ID for media item
	 * @uses self::$media_items_raw
	 * @param string $uri Media item URI
	 * @return string|null Media item ID (Default: NULL if URI doesn't exist in collection)
	 */
	private function get_media_item_id( $uri ) {
		if ( $this->media_item_cached( $uri ) ) {
			return $this->media_items_raw['uri'][ $uri ];
		}
		return null;
	}

	/**
	 * Checks if media item has already been cached
	 * @param string $uri URI of media item
	 * @return boolean Whether media item has been cached
	 */
	private function media_item_cached( $uri ) {
		return ( is_string( $uri ) && ! empty( $uri ) && isset( $this->media_items_raw['uri'][ $uri ] ) ) ? true : false;
	}

	/**
	 * Retrieve cached media item
	 * @param string $uri Media item URI
	 * @return object|null Media item properties (NULL if not set)
	 */
	private function get_cached_media_item( $uri ) {
		$key = $this->get_media_item_id( $uri );
		if ( null !== $key ) {
			return $this->media_items_raw['props'][ $key ];
		}
		return null;
	}

	/**
	 * Retrieve cached media items (properties)
	 * @uses self::$media_items_raw
	 * @return array Cached media items (objects)
	 */
	private function &get_cached_media_items() {
		return $this->media_items_raw['props'];
	}

	/**
	 * Check if media items have been cached
	 * @return boolean
	 */
	private function has_cached_media_items() {
		return ( empty( $this->media_items_raw['props'] ) ) ? false : true;
	}

	/*-** Exclusion **-*/

	/**
	 * Retrieve exclude object
	 * Initialize object properties if necessary
	 * @return object Exclude properties
	 */
	private function get_exclude() {
		// Initialize exclude data
		if ( ! is_object( $this->exclude ) ) {
			$this->exclude = (object) array(
				'tags'          => $this->get_exclude_tags(),
				'ph'            => $this->get_exclude_placeholder(),
				'group_default' => 'default',
				'cache'         => array(),
			);
		}
		return $this->exclude;
	}

	/**
	 * Get exclusion tags (open/close)
	 * Example: open => [slb_exclude], close => [/slb_exclude]
	 *
	 * @return object Exclusion tags
	 */
	private function get_exclude_tags() {
		static $tags = null;
		if ( null === $tags ) {
			/* Init tag elements */
			$tags = (object) [
				// Tag base.
				'base' => $this->add_prefix( 'exclude' ),
			];
			// Opening tag.
			$tags->open = $this->util->add_wrapper( $tags->base );
			// Closing tag.
			$tags->close = $this->util->add_wrapper( $tags->base, '[/', ']' );

			/* Build tag search pattern */

			// Pattern delimeter.
			$dlm = '#';
			// Pattern flags.
			$flags = 's';
			// Tag search pattern.
			$tags->search = $dlm . preg_quote( $tags->open, $dlm ) . '(.*?)' . preg_quote( $tags->close, $dlm ) . $dlm . $flags;
		}
		return $tags;
	}


	/**
	 * Get exclusion tag ("[slb_exclude]")
	 * @uses `get_exclude_tags()` to retrieve tag
	 *
	 * @param string $type (optional) Tag to retrieve (open or close)
	 * @return string Exclusion tag
	 */
	private function get_exclude_tag( $type = 'open' ) {
		// Validate
		$tags = $this->get_exclude_tags();
		if ( ! isset( $tags->{$type} ) ) {
			$type = 'open';
		}
		return $tags->{$type};
	}

	/**
	 * Build exclude placeholder
	 * @return object Exclude placeholder properties
	 */
	private function get_exclude_placeholder() {
		static $ph;
		if ( ! is_object( $ph ) ) {
			$ph = (object) array(
				'base'  => $this->add_prefix( 'exclude_temp' ),
				'open'  => '{{',
				'close' => '}}',
				'attrs' => array(
					'group' => '',
					'key'   => '',
				),
			);
			// Search Patterns
			$sub              = '(.+?)';
			$dlm              = '#';
			$flags            = 's';
			$ph->search       = $dlm . preg_quote( $ph->open, $dlm ) . $ph->base . '\s+' . $sub . preg_quote( $ph->close, $dlm ) . $dlm . $flags;
			$ph->search_group = str_replace( $sub, '(group="%s"\s+.?)', $ph->search );
			// Templates
			$attr_string = '';
			foreach ( $ph->attrs as $attr => $val ) {
				$attr_string .= ' ' . $attr . '="%s"';
			}
			$ph->template = $ph->open . $ph->base . $attr_string . $ph->close;
		}
		return $ph;
	}

	/**
	 * Wrap content in exclusion tags
	 * @uses `get_exclude_tag()` to wrap content with exclusion tag
	 * @param string $content Content to exclude
	 * @return string Content wrapped in exclusion tags
	 */
	private function exclude_wrap( $content ) {
		// Validate
		if ( ! is_string( $content ) ) {
			$content = '';
		}
		// Wrap
		$tags = $this->get_exclude_tags();
		return $tags->open . $content . $tags->close;
	}

	/**
	 * Remove excluded content
	 * Caches content for restoring later
	 * @param string $content Content to remove excluded content from
	 * @return string Updated content
	 */
	public function exclude_content( $content, $group = null ) {
		$ex = $this->get_exclude();
		// Setup cache
		if ( ! is_string( $group ) || empty( $group ) ) {
			$group = $ex->group_default;
		}
		if ( ! isset( $ex->cache[ $group ] ) ) {
			$ex->cache[ $group ] = array();
		}
		$cache =& $ex->cache[ $group ];

		$content = $this->util->apply_filters( 'pre_exclude_content', $content );

		// Search content
		$matches = null;
		if ( false !== strpos( $content, $ex->tags->open ) && preg_match_all( $ex->tags->search, $content, $matches ) ) {
			// Determine index
			$idx = ( ! ! end( $cache ) ) ? key( $cache ) : -1;
			$ph  = array();
			foreach ( $matches[1] as $midx => $match ) {
				// Update index
				$idx++;
				// Cache content
				$cache[ $idx ] = $match;
				// Build placeholder
				$ph[] = sprintf( $ex->ph->template, $group, $idx );
			}
			unset( $midx, $match );
			// Replace content with placeholder
			$content = str_replace( $matches[0], $ph, $content );

			// Cleanup
			unset( $matches, $ph );
		}

		return $content;
	}

	/**
	 * Exclude shortcodes from link activation
	 * @param string $content Content to exclude shortcodes from
	 * @return string Content with shortcodes excluded
	 */
	public function exclude_shortcodes( $content ) {
		// Get shortcodes to exclude
		$shortcodes = $this->util->apply_filters( 'exclude_shortcodes', array( $this->add_prefix( 'group' ) ) );
		// Set callback
		$shortcodes = array_fill_keys( $shortcodes, $this->m( 'exclude_shortcodes_handler' ) );
		return $this->util->do_shortcode( $content, $shortcodes );
	}

	/**
	 * Wrap shortcode in exclude tags
	 * @uses Util->make_shortcode() to rebuild original shortcode
	 *
	 * @param array $attr Shortcode attributes
	 * @param string $content Content enclosed in shortcode
	 * @param string $tag Shortcode name
	 * @return string Excluded shortcode
	 */
	public function exclude_shortcodes_handler( $attr, $content, $tag ) {
		$code = $this->util->make_shortcode( $tag, $attr, $content );
		// Exclude shortcode
		return $this->exclude_wrap( $code );
	}

	/**
	 * Restore excluded content
	 * @param string $content Content to restore excluded content to
	 * @return string Content with excluded content restored
	 */
	public function restore_excluded_content( $content, $group = null ) {
		$ex = $this->get_exclude();
		// Setup cache
		if ( ! is_string( $group ) || empty( $group ) ) {
			$group = $ex->group_default;
		}
		// Nothing to restore if cache group doesn't exist
		if ( ! isset( $ex->cache[ $group ] ) ) {
			return $content;
		}
		$cache =& $ex->cache[ $group ];

		// Search content for placeholders
		$matches = null;
		if ( false !== strpos( $content, $ex->ph->open . $ex->ph->base ) && preg_match_all( $ex->ph->search, $content, $matches ) ) {
			// Restore placeholders
			foreach ( $matches[1] as $idx => $ph ) {
				// Parse placeholder attributes
				$attrs = $this->util->parse_attribute_string( $ph, $ex->ph->attrs );
				// Validate
				if ( $attrs['group'] !== $group ) {
					continue;
				}
				// Restore content
				$attrs['key'] = intval( $attrs['key'] );
				$key          = $attrs['key'];

				if ( isset( $cache[ $key ] ) ) {
					$content = str_replace( $matches[0][ $idx ], $cache[ $key ], $content );
				}
			}
			// Cleanup
			unset( $idx, $ph, $matches, $key );
		}

		return $content;
	}

	/*-** Grouping **-*/

	/**
	 * Builds wrapper for grouping
	 * @return string Format for wrapping content in group
	 */
	function group_get_wrapper() {
		static $fmt = null;
		if ( is_null( $fmt ) ) {
			$fmt = $this->util->make_shortcode( $this->add_prefix( 'group' ), null, '%s' );
		}
		return $fmt;
	}

	/**
	 * Wraps shortcodes for automatic grouping
	 * @uses `the_content` Filter hook
	 * @uses group_shortcodes_handler to Wrap shortcodes for grouping
	 * @param string $content Post content
	 * @return string Modified post content
	 */
	function group_shortcodes( $content ) {
		if ( ! $this->is_content_valid( $content ) ) {
			return $content;
		}
		// Setup shortcodes to wrap
		$shortcodes = $this->util->apply_filters( 'group_shortcodes', array( 'gallery', 'nggallery' ) );
		// Set custom callback
		$shortcodes = array_fill_keys( $shortcodes, $this->m( 'group_shortcodes_handler' ) );
		// Process gallery shortcodes
		return $this->util->do_shortcode( $content, $shortcodes );
	}

	/**
	 * Groups shortcodes for later processing
	 * @param array $attr Shortcode attributes
	 * @param string $content Content enclosed in shortcode
	 * @param string $tag Shortcode name
	 * @return string Grouped shortcode
	 */
	function group_shortcodes_handler( $attr, $content, $tag ) {
		$code = $this->util->make_shortcode( $tag, $attr, $content );
		// Wrap shortcode
		return sprintf( $this->group_get_wrapper(), $code );
	}

	/**
	 * Activate groups in content
	 * @param string $content Content to activate
	 * @return string Updated content
	 */
	public function activate_groups( $content ) {
		return $this->util->do_shortcode( $content, array( $this->add_prefix( 'group' ) => $this->m( 'activate_groups_handler' ) ) );
	}

	/**
	 * Groups shortcodes for later processing
	 * @param array $attr Shortcode attributes
	 * @param string $content Content enclosed in shortcode
	 * @param string $tag Shortcode name
	 * @return string Grouped shortcode
	 */
	function activate_groups_handler( $attr, $content, $tag ) {
		// Get Group ID
		//  Custom group
		if ( isset( $attr['id'] ) ) {
			$group = $attr['id'];
			trim( $group );
		}
		//  Automatically-generated group
		if ( empty( $group ) ) {
			$group = 'auto_' . ( ++$this->groups['auto'] );
		}
		return $this->process_links( $content, $group );
	}

	/*-** Widgets **-*/

	/**
	 * Set widget up for processing/activation
	 * Buffers widget output for further processing
	 * @param array $widget_args Widget arguments
	 * @return void
	 */
	public function widget_process_start( $widget_args ) {
		// Do not continue if a widget is currently being processed (avoid nested processing)
		if ( 0 < $this->widget_processing_level ) {
			return;
		}
		// Start widget processing
		$this->widget_processing        = true;
		$this->widget_processing_params = $widget_args;
		// Enable widget grouping
		if ( $this->options->get_bool( 'group_widget' ) ) {
			$this->util->add_filter( 'get_group_id', $this->m( 'widget_group_id' ) );
		}
		// Begin output buffer
		ob_start();
	}

	/**
	 * Handles inter-widget processing
	 * After widget output generated, Before next widget starts
	 * @param array $params New widget parameters
	 */
	public function widget_process_inter( $params ) {
		$this->widget_process_finish();
		return $params;
	}

	/**
	 * Complete widget processing
	 * Activate widget output
	 * @uses $widget_processing
	 * @uses $widget_processing_level
	 * @uses $widget_processing_params
	 * @return void
	 */
	public function widget_process_finish() {
		/**
		 * Stop processing on conditions:
		 * - No widget is being processed
		 * - Processing a nested widget
		 */
		if ( ! $this->widget_processing || 0 < $this->widget_processing_level ) {
			return;
		}
		// Activate widget output
		$out = $this->activate_links( ob_get_clean() );

		// Clear grouping callback
		if ( $this->options->get_bool( 'group_widget' ) ) {
			$this->util->remove_filter( 'get_group_id', $this->m( 'widget_group_id' ) );
		}
		// End widget processing
		$this->widget_processing        = false;
		$this->widget_processing_params = null;
		// Output widget
		echo $out;
	}

	/**
	 * Add widget ID to link group ID
	 * Widget ID precedes all other group segments
	 * @uses `SLB::get_group_id` filter
	 * @param array $group_segments Group ID segments
	 * @return array Modified group ID segments
	 */
	public function widget_group_id( $group_segments ) {
		// Add current widget ID to group ID
		if ( isset( $this->widget_processing_params['id'] ) ) {
			array_unshift( $group_segments, $this->widget_processing_params['id'] );
		}
		return $group_segments;
	}

	/**
	 * Handles nested activation in widgets
	 * @uses widget_processing
	 * @uses $widget_processing_level
	 * @return void
	 */
	public function widget_process_nested() {
		// Stop if no widget is being processed
		if ( ! $this->widget_processing ) {
			return;
		}

		// Increment nesting level
		$this->widget_processing_level++;
	}

	/**
	 * Mark the end of a nested widget
	 * @uses $widget_processing_level
	 */
	public function widget_process_nested_finish() {
		// Decrement nesting level
		if ( 0 < $this->widget_processing_level ) {
			$this->widget_processing_level--;
		}
	}

	/**
	 * Begin blocking widget activation
	 * @return void
	 */
	public function widget_block_start() {
		$this->util->add_filter( 'is_content_valid', $this->m( 'widget_block_handle' ) );
	}

	/**
	 * Stop blocking widget activation
	 * @return void
	 */
	public function widget_block_finish() {
		$this->util->remove_filter( 'is_content_valid', $this->m( 'widget_block_handle' ) );
	}

	/**
	 * Handle widget activation blocking
	 */
	public function widget_block_handle( $is_content_valid ) {
		return false;
	}

	/*-** Menus **-*/

	/**
	 * Process navigation menu links
	 *
	 * @see wp_nav_menu()/filter: wp_nav_menu
	 *
	 * @param string $nav_menu HTML content for navigation menu.
	 * @param object $args     Navigation menu's arguments.
	 */
	public function menu_process( $nav_menu, $args ) {
		// Grouping
		if ( $this->options->get_bool( 'group_menu' ) ) {
			// Generate group ID for menu
			$group = 'menu';
			$sep   = '_';
			if ( ! empty( $args->menu_id ) ) {
				$group .= $sep . $args->menu_id;
			} elseif ( ! empty( $args->menu ) ) {
				$group .= $sep . ( ( is_object( $args->menu ) ) ? $args->menu->slug : $args->menu );
			}
			$group = $this->group_id_unique( $group );
		} else {
			$group = null;
		}

		// Process menu
		$nav_menu = $this->activate_links( $nav_menu, $group );

		return $nav_menu;
	}

	/**
	 * Generate unique group ID
	 *
	 * @param string $group Group ID to check
	 * @return string Unique group ID
	 */
	public function group_id_unique( $group ) {
		static $groups = array();
		while ( in_array( $group, $groups, true ) ) {
			$patt = '#-(\d+)$#';
			if ( preg_match( $patt, $group, $matches ) ) {
				$group = preg_replace( $patt, '-' . ( ++$matches[1] ), $group );
			} else {
				$group = $group . '-1';
			}
		}
		// Add final group ID to array
		$groups[] = $group;
		return $group;
	}

	/*-** Helpers **-*/

	/**
	 * Build attribute name
	 * Makes sure name is only prefixed once
	 * @param string $name (optional) Attribute base name
	 * @return string Formatted attribute name
	 */
	function make_attribute_name( $name = '' ) {
		// Validate
		if ( ! is_string( $name ) ) {
			$name = '';
		} else {
			$name = trim( $name );
		}
		// Setup
		$sep = '-';
		$top = 'data';
		// Generate valid name
		if ( strpos( $name, $top . $sep . $this->get_prefix() ) !== 0 ) {
			$name = $top . $sep . $this->add_prefix( $name, $sep );
		}
		return $name;
	}

	/**
	 * Sets attribute value.
	 *
	 * Attribute is added to array if it does not already exist.
	 *
	 * @param string|array $attrs Array to set attribute on (Passed by reference).
	 * @param string $name Name of attribute to set.
	 * @param scalar $value Optional. Attribute value.
	 * @return array Updated attributes.
	 */
	function set_attribute( &$attrs, $name, $value = true ) {
		// Validate
		$attrs = $this->get_attributes( $attrs, false );
		// Stop if attribute name or value is invalid.
		if ( ! is_string( $name ) || empty( trim( $name ) ) || ! is_scalar( $value ) ) {
			return $attrs;
		}
		// Set attribute value.
		$attrs = array_merge( $attrs, array( $this->make_attribute_name( $name ) => $value ) );

		return $attrs;
	}

	/**
	 * Converts attribute string into array.
	 *
	 * @param string|array $attrs Attribute string to convert. Associative array also accepted.
	 * @param bool $internal Optional. Return only internal attributes. Default True.
	 * @return array Associative array of attributes (`attribute-name => attribute-value`).
	 */
	function get_attributes( $attrs, $internal = true ) {
		// Parse attribute string.
		$attrs = $this->util->parse_attribute_string( $attrs );
		// Include only internal attributes (if necessary).
		if ( ! ! $internal && ! empty( $attrs ) ) {
			$prefix = $this->make_attribute_name();
			$attrs  = array_filter(
				$attrs,
				function( $key ) use ( $prefix ) {
					return ( strpos( $key, $prefix ) === 0 );
				},
				ARRAY_FILTER_USE_KEY
			);
		}

		return $attrs;
	}

	/**
	 * Retrieves an attributes value from attribute string or array.
	 *
	 * @param string|array $attrs Attribute string to retrieve attribute value from.
	 *                            Associative array also accepted.
	 * @param string $attr Attribute to retrieve value for.
	 * @param bool $internal Optional. Retrieve internal attribute. Default true.
	 * @return scalar|null Attribute value. Null if attribute is invalid or does not exist.
	 */
	function get_attribute( $attrs, $attr, $internal = true ) {
		// Validate.
		$invalid = null;
		if ( ! is_string( $attr ) || empty( trim( $attr ) ) ) {
			return $invalid;
		}
		$attrs = $this->get_attributes( $attrs, $internal );
		if ( empty( $attrs ) ) {
			return $invalid;
		}
		// Format attribute name.
		$attr = ( ! ! $internal ) ? $this->make_attribute_name( $attr ) : trim( $attr );
		// Stop if attribute does not exist or value is invalid.
		if ( ! isset( $attrs[ $attr ] ) || ! is_scalar( $attrs[ $attr ] ) ) {
			return $invalid;
		}
		// Retreive value.
		$ret = $attrs[ $attr ];
		// Validate value (type-specific).
		if ( is_string( $ret ) ) {
			$ret = trim( $ret );
			if ( '' === $ret ) {
				return $invalid;
			}
		}

		return $ret;
	}

	/**
	 * Checks if attribute exists.
	 *
	 * @param string|array $attrs Attribute string to retrieve attribute value from.
	 *                            Associative array also accepted.
	 * @param string $attr Attribute to retrieve value for.
	 * @param bool $internal Optional. Retrieve internal attribute. Default true.
	 * @return bool True if attribute exists. False if attribute does not exist.
	 */
	function has_attribute( $attrs, $attr, $internal = true ) {
		return ( null !== $this->get_attribute( $attrs, $attr, $internal ) );
	}

	/**
	 * Build JS object of UI strings when initializing lightbox
	 * @return array UI strings
	 */
	private function build_labels() {
		$ret = array();
		/* Get all UI options */
		$prefix      = 'txt_';
		$opt_strings = array_filter(
			array_keys( $this->options->get_items() ),
			function ( $opt ) use ( $prefix ) {
				return ( strpos( $opt, $prefix ) === 0 );
			}
		);
		if ( count( $opt_strings ) ) {
			/* Build array of UI options */
			foreach ( $opt_strings as $key ) {
				$name         = substr( $key, strlen( $prefix ) );
				$ret[ $name ] = $this->options->get_value( $key );
			}
		}
		return $ret;
	}
}
