<?php
/**
 * @brief		Skin Set
 * @author		<a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
 * @copyright	(c) Invision Power Services, Inc.
 * @license		https://www.invisioncommunity.com/legal/standards/
 * @package		Invision Community
 * @since		16 Apr 2013
 */

namespace IPS;

/* To prevent PHP errors (extending class does not exist) revealing path */

use Diff;
use DomainException;
use DOMText;
use DOMXPath;
use ErrorException;
use Exception;
use InvalidArgumentException;
use IPS\cms\Pages\Page;
use IPS\Data\Store;
use IPS\Dispatcher\Front;
use IPS\Helpers\Form;
use IPS\Helpers\Form\CheckboxSet;
use IPS\Helpers\Form\Codemirror;
use IPS\Helpers\Form\Number;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\Translatable;
use IPS\Helpers\Form\YesNo;
use IPS\Http\Url;
use IPS\Http\Url\Friendly;
use IPS\Member\Group;
use IPS\Node\Model;
use IPS\Patterns\ActiveRecord;
use IPS\Patterns\ActiveRecordIterator;
use IPS\Theme\CustomTemplate;
use IPS\Theme\Editor\Category;
use IPS\Theme\Editor\Setting;
use IPS\Theme\SandboxedTemplate;
use IPS\Theme\Dev\Theme as DevTheme;
use IPS\Theme\Setup\Theme as SetupTheme;
use IPS\Theme\TemplateException;
use IPS\Xml\DOMDocument;
use LogicException;
use OutOfRangeException;
use OverflowException;
use ParseError;
use UnderflowException;
use UnexpectedValueException;
use XMLReader;
use function array_combine;
use function array_merge;
use function count;
use function defined;
use function file_get_contents;
use function file_put_contents;
use function function_exists;
use function get_called_class;
use function glob;
use function implode;
use function in_array;
use function intval;
use function is_array;
use function is_string;
use function iterator_to_array;
use function json_decode;
use function json_encode;
use function mb_substr;
use function md5;
use function preg_match;
use function preg_match_all;
use function preg_replace;
use function rtrim;
use function str_replace;
use function stristr;
use function strlen;
use function strstr;
use function strtolower;
use function substr;
use function unlink;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * Skin set
 */
class Theme extends Model
{
	/**
	 * @brief	[ActiveRecord] Multiton Store
	 */
	protected static array $multitons;
	
	/**
	 * @brief	[ActiveRecord] Database Table
	 */
	public static ?string $databaseTable = 'core_themes';
	
	/**
	 * @brief	[ActiveRecord] Database Prefix
	 */
	public static string $databasePrefix = 'set_';
	
	/**
	 * @brief	[ActiveRecord] ID Database Column
	 */
	public static string $databaseColumnId = 'id';
	
	/**
	 * @brief	[ActiveRecord] Database ID Fields
	 */
	protected static array $databaseIdFields = array('set_id', 'set_key', 'set_edit_in_progress');
	
	/**
	 * @brief	[ActiveRecord] Multiton Map
	 */
	protected static array $multitonMap	= array();

	/**
	 * @brief	[Node] Order Database Column
	 */
	public static ?string $databaseColumnOrder = 'order';
	
	/**
	 * @brief	[Node] Node Title
	 */
	public static string $nodeTitle = 'menu__core_customization_themes';
	
	/**
	 * @brief	[Node] Show forms modally?
	 */
	public static bool $modalForms = TRUE;

	/**
	 * @brief	[Node] Title prefix.  If specified, will look for a language key with "{$key}_title" as the key
	 */
	public static ?string $titleLangPrefix = 'core_theme_set_title_';
	
	/**
	 * @brief	IN_DEV "theme"
	 */
	public static ?Theme $inDevTheme = NULL;
	
	/**
	 * @brief	Setup "theme"
	 */
	protected static Theme|SetupTheme|null $setupSkin = NULL;
	
	/**
	 * @brief	Admin "theme"
	 */
	public static Theme|DevTheme|null $adminSkin = NULL;
	
	/**
	 * @brief	Member's "theme"
	 */
	public static mixed $memberTheme = NULL;
	
	/**
	 * @brief	Have fetched all?
	 */
	protected static bool $gotAll = FALSE;
	
	/**
	 * @brief	[SkinSets] Stores the default theme set id
	 */
	public static int $defaultFrontendThemeSet = 0;
	
	/**
	 * @brief	[SkinSets] Templates already loaded and evald via getTemplate()
	 */
	public static array $calledTemplates = array();
	
	/**
	 * @brief	[SkinSets] Some CSS files are built from a directory to save on http requests. They are saved as {$location}_{$folder}.css (so front_responsive.css for example)
	 */
	protected static array $buildGrouping = array(
		'css'  => array(
			'core' => array(
				'global'=> array( 'framework' ),
				'admin' => array( 'core' )
				)
			)
		);
	
	/**
	 * @brief	Return type for getAllTemplates/getRawCss: Return all
	 */
	const RETURN_ALL = 1;
	
	/**
	 * @brief	Return type for getAllTemplates/getRawCss: Return groups and names as a tree
	 */
	const RETURN_BIT_NAMES = 2;
	
	/**
	 * @brief	Return type for getAllTemplates/getRawCss: Return groups and names as a tree with array of data without content
	 */
	const RETURN_ALL_NO_CONTENT = 4;
	
	/**
	 * @brief	Return type for getAllTemplates/getRawCss: Returns bit names as a flat array
	 */
	const RETURN_ARRAY_BIT_NAMES = 8;
	
	/**
	 * @brief	Return type for getAllTemplates/getRawCss: Uses DB if not IN_DEV, otherwise uses disk .phtml look up
	 */
	const RETURN_NATIVE = 16;

	/**
	 * @brief	Type for templates
	 */
	const TEMPLATES = 1;
	
	/**
	 * @brief	Type for CSS
	 */
	const CSS = 2;
	
	/**
	 * @brief	Type for Images
	 */
	const IMAGES = 4;
	
	/**
	 * @brief Bit option for theme settings
	 */
	const THEME_KEY_VALUE_PAIRS = 1;
	
	/**
	 * @brief Bit option for theme settings
	 */
	const THEME_ID_KEY = 2;
	
	/**
	 * @brief Bit option for theme settings
	 */
	const TEMPLATE_SETTINGS_DEFAULT = 4;

	/**
	 * @brief Bit option for getting css variables
	 */
	const CUSTOM_ONLY = 1;

	/**
	 * @brief Bit option to use the default values
	 */
	const FORCE_DEFAULT = 2;

	/**
	 * @brief Name of variable filename
	 */
	const CSS_VARIABLE_FILENAME = '1-2-settings.css';

	/**
	 * @brief	Disable the copy button - useful when the forms are very distinctly different
	 */
	public bool $noCopyButton = TRUE;

	/**
	 * @brief	[ActiveRecord] Caches
	 * @note	Defined cache keys will be cleared automatically as needed
	 */
	protected array $caches = array( 'updatecount_themes' );

	/**
	 * @brief Save a few cycles not json_decoding constantly
	 */
	protected array $_layoutValues = [];
	
	/**
	 * Get currently logged in member's theme
	 *
	 * @return    static|SetupTheme|DevTheme
	 */
	public static function i(): static|SetupTheme|DevTheme
	{
		if ( Dispatcher::hasInstance() AND class_exists( '\IPS\Dispatcher', FALSE ) and Dispatcher::i()->controllerLocation === 'setup' )
		{
			if ( static::$setupSkin === NULL )
			{
				static::$setupSkin = new SetupTheme;
			}
			return static::$setupSkin;
		}
		else if (RECOVERY_MODE)
		{
			if ( static::$memberTheme === NULL )
			{
				foreach( static::themes() as $theme )
				{
					if ( ! $theme->isCustomized() )
					{
						static::$memberTheme = $theme;
						break;
					}
				}
			}

			/* Set Admin Theme */
			if ( static::$adminSkin === NULL )
			{
				static::$adminSkin = static::master();
			}

			/* Still here because all themes are customized? */
			if ( static::$memberTheme === NULL )
			{
				$newTheme = new Theme;
				$newTheme->permissions = Member::loggedIn()->member_group_id;
				$newTheme->save();
				
				Lang::saveCustom( 'core', "core_theme_set_title_" . $newTheme->id, "IPS Default" );
				
				static::$memberTheme = $newTheme;
			}

			return static::$memberTheme;
		}
		else if (IN_DEV)
		{
			if ( static::$inDevTheme === NULL )
			{
				static::$inDevTheme = new DevTheme();
				static::themes();

				/* Add in the default theme properties (_data array, etc) */
				$default = ( isset( static::$multitons[DEFAULT_THEME_ID] ) ) ? static::$multitons[DEFAULT_THEME_ID] : reset( static::$multitons );

				foreach( $default as $k => $v )
				{
					static::$inDevTheme->$k = $v;
				}
			}

			if ( Member::loggedIn()->isEditingTheme() )
			{
				try
				{
					static::$memberTheme = static::byEditingMember();
				}
				catch( Exception )
				{
					static::$memberTheme = static::load( static::defaultTheme() );
				}
			}

			return static::$inDevTheme;
		}
		else if ( Dispatcher::hasInstance() AND class_exists( '\IPS\Dispatcher', FALSE ) and Dispatcher::i()->controllerLocation === 'admin' )
		{
			if ( static::$adminSkin === NULL )
			{
				static::$adminSkin = static::master();
			}
			return static::$adminSkin;
		}
		else
		{
			if ( static::$memberTheme === NULL )
			{
				static::themes();
				
				$setId = NULL;
				if ( Dispatcher::hasInstance() and Dispatcher::i()->controllerLocation == 'front' )
				{
					$setId = static::currentThemeId();
					
					if ( $setId )
					{
						if( ! Request::i()->isAjax() and Session\Front::loggedIn() )
						{
							/* Not an ajax call, so reset theme_id */
							$setId = NULL;
							Session\Front::i()->setTheme(0);
						}
						else
						{
							try
							{
								if ( static::load( $setId )->canAccess() !== true )
								{
									$setId = NULL;
								}
							}
							catch ( OutOfRangeException $ex )
							{
								$setId = NULL;
							}
						}

					}
				}

				if ( ! $setId and Member::loggedIn()->skin and array_key_exists( Member::loggedIn()->skin, static::themes() ) )
				{
					$setId = Member::loggedIn()->skin;
						
					if ( static::load( $setId )->canAccess() !== true )
					{
						$setId = static::defaultTheme();
						
						/* Restore default theme for member */
						Member::loggedIn()->skin = $setId;

						if( Member::loggedIn()->member_id )
						{
							Member::loggedIn()->save();
						}
					}
				}
				else if ( ! $setId )
				{
					$setId = static::defaultTheme();
				}

				if ( Member::loggedIn()->isEditingTheme() )
				{
					try
					{
						static::$memberTheme = static::byEditingMember();
					}
					catch( Exception )
					{
						static::$memberTheme = static::load( $setId );
					}
				}
				else
				{
					static::$memberTheme = static::load( $setId );
				}
			}
			
			return static::$memberTheme;
		}
	}
	
	/**
	 * Get current theme id
	 *
	 * @return int|null
	 */
	public static function currentThemeId(): ?int
	{
		if ( Session\Front::loggedIn() )
		{
			return Session\Front::i()->getTheme();
		}
		
		return isset( Request::i()->cookie['theme'] ) ? Request::i()->cookie['theme'] : NULL;
	}
	
	/**
	 * Themes
	 *
	 * @return	array
	 */
	public static function themes(): array
	{
		if ( !static::$gotAll )
		{
			static::$gotAll = true;
			
			if ( isset( Store::i()->themes ) )
			{
				$rows = Store::i()->themes;
			}
			else
			{
				$rows = iterator_to_array( Db::i()->select( '*', 'core_themes', NULL, 'set_order' )->setKeyField('set_id') );
				Store::i()->themes = $rows;
			}
			
			foreach( $rows as $id => $theme )
			{
				if ( $theme['set_is_default'] )
				{
					static::$defaultFrontendThemeSet = $theme['set_id'];
				}
				
				static::$multitons[ $theme['set_id'] ] = static::constructFromData( $theme );
			}

			/* Load all theme settings upfront */
			Setting::loadAllKeys();
		}
		return static::$multitons;
	}

	/**
	 * Returns only the visible themes for the member
	 *
	 * @return array
	 */
	public static function getThemesWithAccessPermission(): array
	{
		$visibleThemes = array();
		foreach(Theme::roots() as $theme )
		{
			if ( $theme->canAccess() )
			{
				$visibleThemes[$theme->id] = $theme;
			}
		}

		return $visibleThemes;
	}

	/**
	 * Fetch the master theme object
	 *
	 * @return Theme|DevTheme
	 */
	public static function master(): Theme|DevTheme
	{
		static::themes();
		return static::$multitons[ static::$defaultFrontendThemeSet ];
	}

	/**
	 * Default Frontend Skin Set ID
	 *
	 * @return	int
	 */
	public static function defaultTheme(): int
	{
		if ( !static::$gotAll )
		{
			static::themes();
		}
	
		return static::$defaultFrontendThemeSet;
	}

	/**
	 * Switches the currently initialized theme during execution
	 *
	 * @param int $themeId        Id of the theme to switch to
	 * @param boolean $persistent		Allow to persist through ajax calls
	 * @return  boolean
	 * @note    This will not check to ensure the member has permission to view the theme
	 */
	public static function switchTheme( int $themeId, bool $persistent=TRUE ): bool
	{
		static::themes();

		try
		{
			/* Make sure the member theme is set */
			Theme::i();

			$class = static::i();
			static::$memberTheme = $class::load( $themeId );
			
			/* Store the ID in sessions so ajax loads correct theme */
			if ( $persistent )
			{
				Session\Front::i()->setTheme( $themeId );
			}
			
			/* Flush loaded CSS */
			Output::i()->cssFiles = array();

			Front::baseCss();

			/* App CSS */
			$app = Front::i()->application;
			$app::outputCss();
		}
		catch( OutOfRangeException $e )
		{
			return FALSE;
		}

		return TRUE;
	}
	
	/**
	 * @brief Prevent CSS from being loaded more than once
	 */
	protected static array $usedCss = array();
	
	/**
	 * Get CSS
	 * This method is used to return the built CSS stored in the file objects system
	 *
	 * @param string $file		Filename
	 * @param string|null $app		Application
	 * @param string|null $location	Location (e.g. 'admin', 'front')
	 * @return	array		URLs to CSS files
	 */
	public function css( string $file, string $app=NULL, string $location=NULL ): array
	{
		$app      = $app      ?: Dispatcher::i()->application->directory;
		$location = $location ?: Dispatcher::i()->controllerLocation;
		$paths    = explode( '/', $file );
		$name     = array_pop( $paths );
		$path     = ( count( $paths ) ) ? implode( '/', $paths ) : '.';

		$cacheKey = $file . ',' . $app . ',' . $location . ',' . $this->_id;
		
		if ( isset( static::$usedCss[ $cacheKey ] ) )
		{
			return static::$usedCss[ $cacheKey ];
		}
		
		if ( $location === 'interface' )
		{
			/* Legacy code support. The following directories were moved to /static */
			if ( ( $app === "core" or $app === null) and preg_match( "/^(?:codemirror|fontawesome)/", $file ) )
			{
				$file = "static/" . $file;
			}

			static::$usedCss[ $cacheKey ] = array( rtrim( Url::baseUrl( Url::PROTOCOL_RELATIVE ), '/' ) . "/applications/{$app}/interface/{$file}" );
			return static::$usedCss[ $cacheKey ];
		}

		$key = static::cssFilePrefix( $app, $location, $path );

		if( in_array( $app, IPS::$ipsApps ) AND $file != 'custom.css' )
		{
			static::$usedCss[ $cacheKey ] = array( rtrim( Url::baseUrl( Url::PROTOCOL_RELATIVE ), '/' ) . "/static/css/" . $key . "_{$name}" );
			return static::$usedCss[ $cacheKey ];
		}

		/* We use a different format for when we compile CSS outside first party static files */
		$cssMapKey = static::makeBuiltTemplateLookupHash( $app, $location, str_contains( $file, '/' ) ? $file : './' . $file );

		if( array_key_exists( $cssMapKey, $this->css_map ) )
		{
			if ( $this->css_map[ $cssMapKey ] !== null )
			{
				if( !in_array( $app, IPS::$ipsApps ) or $file == 'custom.css' )
				{
					static::$usedCss[ $cacheKey ] = array( File::get( 'core_Theme', $this->css_map[ $cssMapKey ] )->url );
				}
			}
			else
			{
				static::$usedCss[ $cacheKey ] = array();
			}
			
			return static::$usedCss[ $cacheKey ];
		}
		else
		{
			/* We're setting up, do nothing to avoid compilation requests when tables are incomplete */
			if ( ! isset( Settings::i()->setup_in_progress ) OR Settings::i()->setup_in_progress )
			{
				/* Do not store a cache here as this will break later when we want CSS */
				//return array();
				// If we don't let the CSS rebuild here as needed then the AdminCP upgrader can result in a completely unstyled page when interrupted
			}
			
			/* Map doesn't exist, try and create it */
			if ( $this->compileCss( $app, $location, $path, $name ) === NULL )
			{
				/* Do not store a cache here as this will break later when we want CSS */
				return array();
			}
			
			/* Still not here? Then add a key but as null to prevent it from attempting to rebuild on every single page
			 * load thus hitting the DB multiple times */
			$cssMap = $this->css_map;
			if ( ! in_array( $cssMapKey, array_keys( $this->css_map ) ) )
			{
				$cssMap[ $cssMapKey ] = null;
				
				$this->css_map = $cssMap;
				$this->save();
			}
			else
			{
				if( in_array( $app, IPS::$ipsApps ) AND $file != 'custom.css' )
				{
					static::$usedCss[ $cacheKey ] = array( "/static/css/". $key . "_{$name}" );
				}
				else
				{
					try
					{
						static::$usedCss[ $cacheKey ] = array( File::get( 'core_Theme', $this->css_map[ $cssMapKey ] )->url );
					}
					catch( Exception )
					{
						static::$usedCss[ $cacheKey ] = array();
					}
				}

				return static::$usedCss[ $cacheKey ];
			}
		}
		
		return array();
	}

	/**
	 * Returns a human brain digestable unique prefix for CSS files
	 *
	 * @param string $app
	 * @param string $location
	 * @param string $path
	 * @return	string	Md5 Key
	 */
	public static function cssFilePrefix( string $app, string $location, string $path ): string
	{
		return str_replace( '/', '-', mb_strtolower( $app ) . '_' . mb_strtolower( $location ) . ( ( $path and $path != '.' ) ? '_' . mb_strtolower( $path ) : '' ) );
	}

	/**
	 * Resets the hook point flags in the core_theme_templates database
	 *
	 * @param string|null $app
	 * @return void
	 */
	public static function rebuildHookPointFlags( string $app=null ): void
	{
		/* Reset flags */
		$where = $app ? [ 'template_app=?', $app ] : [];
		Db::i()->update( 'core_theme_templates', [ 'template_has_hookpoints' => 0 ], $where );

		/* We might need to get more fancy at some point but now that no one can edit templates, it's unlikely data-ips-hook= will be used in any other way.
		   If it becomes a problem, we can simply iterate over and use xpath to check for reals. */
		Db::i()->update( 'core_theme_templates', [ 'template_has_hookpoints' => 1 ], [ array_merge( $where, Db::i()->like( 'template_content', ' data-ips-hook=', FALSE, TRUE, TRUE ) ) ] );
	}

	/**
	 * Get Theme Resource (image, font, theme-specific JS, etc)
	 *
	 * @param string $path		Path to resource
	 * @param string|null $app		Application key
	 * @param string|null $location	Location
	 * @return    Url|string|NULL		URL to resource
	 */
	public function resource( string $path, string $app=NULL, string $location=NULL, $noProtocol=FALSE ): Url|string|null
	{
		$app      = $app      ?: Dispatcher::i()->application->directory;
		$location = $location ?: Dispatcher::i()->controllerLocation;
		$paths    = explode( '/', $path );
		$name     = array_pop( $paths );
		$path     = ( count( $paths ) ) ? ( '/' . implode( '/', $paths ) . '/' ) : '/';
		$key      = static::makeBuiltTemplateLookupHash($app, $location, $path) . '_' .$name;

		if ( $location === 'interface' )
		{
			return Url::internal( "applications/{$app}/interface{$path}{$name}", 'interface', NULL, array(), Url::PROTOCOL_RELATIVE );
		}

		/* Are these core IPS apps? If so, we store these in the static directory and won't be in the resource map */
		if( in_array( $app, IPS::$ipsApps ) and str_replace( '/', '', $path ) !== 'custom' )
		{
			$url = Url::internal( 'static/resources/' . $app . '_' . static::makeBuiltTemplateLookupHash($app, $location, $path) . '_' .$name, 'static' );

			if( $noProtocol )
			{
				$url = $url->setScheme(NULL);
			}

			return $url;
		}

		/* Make sure that resource_map is an array */
		if( !is_array( $this->resource_map ) )
		{
			if( $json = json_decode( $this->resource_map, TRUE ) )
			{
				$this->resource_map = $json;
			}
			else
			{
				return NULL;
			}
		}

		if ( in_array( $key, array_keys( $this->resource_map ) ) )
		{
			if ( $this->resource_map[ $key ] === NULL )
			{
				return NULL;
			}
			else
			{
				$url = File::get( 'core_Theme', $this->resource_map[ $key ] )->url;

				if( $noProtocol and $url instanceof Url )
				{
					$url = $url->setScheme(NULL);
				}

				return $url;
			}
		}
		
		/* Still here? Map doesn't exist, try and create it */
		$resourceMap = $this->resource_map;
		
		try
		{
			/* We're setting up, do nothing to avoid compilation requests when tables are incomplete */
			if ( ! isset( Settings::i()->setup_in_progress ) OR Settings::i()->setup_in_progress )
			{
				return NULL;
			}
			
			$flagKey  = 'resource_compiling_' . $this->_id . '_' . md5( $key );
			if ( static::checkLock( $flagKey ) )
			{
				return NULL;
			}
	
			static::lock( $flagKey );
		
			$resource = Db::i()->select( '*', 'core_theme_resources', array( 'resource_set_id=? AND resource_app=? AND resource_location=? AND resource_path=? AND resource_name=?', $this->id, $app, $location, $path, $name ) )->first();
		
			$resourceMap[ $key ] = (string) File::create( 'core_Theme', $key, $resource['resource_data'], 'set_resources_' . $this->id, TRUE, NULL, FALSE );
			Db::i()->update( 'core_theme_resources', array( 'resource_added' => time(), 'resource_filename' => $resourceMap[ $key ] ), array( 'resource_id=?', $resource['resource_id'] ) );
			
			/* Save map */
			$this->resource_map = $resourceMap;
			$this->save();
			
			static::unlock( $flagKey );

			if( in_array( $app, IPS::$ipsApps ) and $resource['resource_path'] !== '/custom/' )
			{
				$url = rtrim( Settings::i()->base_url, '/' ) . '/' . $resourceMap[ $key ];
			}
			else
			{
				$url = File::get( 'core_Theme', $resourceMap[ $key ] )->url;
			}

			if( $noProtocol and $url instanceof Url)
			{
				$url = $url->setScheme(NULL);
			}

			return $url;
		}
		catch( UnderflowException $e )
		{
			/* Doesn't exist, add null entry to map to prevent it from being rebuilt on each page load */
			$resourceMap[ $key ] = null;
			
			/* Save map */
			$this->resource_map = $resourceMap;
			$this->save();
			
			return NULL;
		}
	}

	/**
	 * Get a template
	 *
	 * @param string $group Template Group
	 * @param string|null $app Application key (NULL for current application)
	 * @param string|null $location Template Location (NULL for current template location)
	 * @return    mixed
	 * @throws    UnexpectedValueException
	 * @throws ErrorException
	 */
	public function getTemplate( string $group, string $app=NULL, string $location=NULL ): mixed
	{
		/* Do we have an application? */
		if( $app === NULL )
		{
			$app = Dispatcher::i()->application->directory;
		}
			
		/* How about a template location? */
		if( $location === NULL )
		{
			$location = Dispatcher::i()->controllerLocation;
		}

		$hashedKey = strtolower( static::makeBuiltTemplateLookupHash( $app, $location, $group ) . '_' . static::cleanGroupName( $group ) );
		$key = 'template_' . $this->id . '_' . $hashedKey;
		
		$cachedObject = NULL;
		$class = "\\IPS\\Theme\\" . 'class_' . $app . '_' . $location . '_' . $group;

		/* First-party apps should be pre-compiled */
		if( in_array( $app, IPS::$ipsApps ) AND !IN_DEV )
		{
			$templateFile = ROOT_PATH . '/static/templates/' . $app . '_' . $location . '_' . $group . '.php';
			if( file_exists( $templateFile ) )
			{
				require_once( $templateFile );
				return new SandboxedTemplate( new $class( $app, $location, $group ) );
			}
		}
		
		/* We cannot use isset( static::$calledTemplates[ $key ] ) here because it fails with NULL while in_array does not */
		if ( !in_array( $hashedKey, array_keys( static::$calledTemplates ) ) )
		{
			/* If we don't have a compiled template, do that now */
			if ( ! $cachedObject and !isset( Store::i()->$key ) )
			{
				/* It can take a few seconds for templates to finish compiling if initiated elsewhere, so let's try a few times sleeping 1 second between attempts
				   to give the compilation time to finish */
				$attempts = 0;
				while( $attempts < 6 )
				{
					if ( $attempts === 5 )
					{
						/* Rebuild in progress */
						Log::debug( "Template store key: {$key} rebuilding and requested again ({$app}, {$location}, {$group})", "template_store_building" );
	
						/* Since we can't do anything else, this ends up just being an uncaught exception - show the error page right away to avoid the unnecessary logging */
						IPS::genericExceptionPage();
					}
					
					$built = $this->compileTemplates( $app, $location, $group );
					
					if ( $built === NULL )
					{
						$attempts++;
						sleep(1);
					}
					else
					{
						break;
					}
				}
				
				/* Still no key? */
				if ( ! isset( Store::i()->$key ) )
				{
					Log::log( "Template store key: {$key} missing ({$app}, {$location}, {$group})", "template_store_missing" );
	
					throw new ErrorException( 'template_store_missing' );
				}
			}

			/* Load compiled template */
			if ( $cachedObject )
			{
				/* Init */
				static::$calledTemplates[ $hashedKey ] = new SandboxedTemplate( $cachedObject );
			}
			else
			{
				$compiledGroup = Store::i()->$key;
				
				if (DEBUG_TEMPLATES)
				{
					static::runDebugTemplate( $key, $compiledGroup );
				}
				else
				{
					try
					{
						if ( @eval( $compiledGroup ) === FALSE )
						{
							throw new TemplateException( 'Invalid Template', 1000, NULL, array( 'group' => $group, 'app' => $app, 'location' => $location ), $this );
						}
					}
					catch ( ParseError $e )
					{
						throw new UnexpectedValueException;
					}
				}
				
				/* Init */
				static::$calledTemplates[ $hashedKey ] = new SandboxedTemplate( new $class( $app, $location, $group ) );
			}
		}

		return static::$calledTemplates[ $hashedKey ];
	}

	/**
	 * Determine the path to load the file from
	 *
	 * @param string $file	File path
	 * @return	string
	 */
	public static function getHookPath( string $file ): string
	{
		$path = ROOT_PATH;
		if ( CIC2 AND IPS::isThirdParty( $file ) )
		{
			$path = SITE_FILES_PATH;
		}

		return $path;
	}

	/*! Active Record */
	
	/**
	 * Construct ActiveRecord from database row
	 *
	 * @param array $data							Row from database table
	 * @param bool $updateMultitonStoreIfExists	Replace current object in multiton store if it already exists there?
	 * @return    ActiveRecord
	 */
	public static function constructFromData( array $data, bool $updateMultitonStoreIfExists = TRUE ): ActiveRecord
	{
		$obj = parent::constructFromData( $data, $updateMultitonStoreIfExists );

		/* Extra set up */
		$logo = json_decode( $obj->logo_data, true );
		
		$obj->_data['logo'] = array( 'front' => null, 'front-dark' => null, 'mobile' => null, 'mobile-dark' => null );
		
		if ( is_array( $logo ) )
		{
			foreach( [ 'front', 'front-dark', 'mobile', 'mobile-dark' ] as $type )
			{
				if ( isset( $logo[$type] ) )
				{
					$obj->_data['logo'][$type] = $logo[$type];
				}
			}
		}

		if ( ! is_array( $obj->_data['resource_map'] ) )
		{
			if( $obj->id > 0 )
			{
				if ( $imgMap = json_decode( $obj->_data['resource_map'], true ) )
				{
					$obj->_data['resource_map'] = $imgMap;
				}
				else
				{
					$obj->_data['resource_map'] = array();
				}
			}
			else
			{
				if( !isset( Store::i()->acp_resource_map ) )
				{
					$obj->_data['resource_map'] = [];
				}
				else
				{
					$obj->_data['resource_map'] = Store::i()->acp_resource_map;
				}
			}
		}
		
		if ( ! is_array( $obj->_data['css_map'] ) )
		{
			if ( $obj->id > 0 )
			{
				if ( $cssMap = json_decode( $obj->_data['css_map'], true ) )
				{
					$obj->_data['css_map'] = $cssMap;
				}
				else
				{
					$obj->_data['css_map'] = array();
				}
			}
			else
			{
				$obj->_data['css_map'] = json_decode( Settings::i()->acp_css_map, true );
			}
		}

		return $obj;
	}

    public function get_logo() : array
    {
        if( isset( $this->_data['logo'] ) )
        {
            $update = false;
            foreach( $this->_data['logo'] as $type => $logo )
            {
                if( isset( $logo['filename'] ) and empty( $logo['url'] ) )
                {
                    $update = true;
					/* Check to see if $logo['filename'] starts with a 'monthly_' */
					if ( str_starts_with( $logo['filename'], 'monthly_' ) )
					{
						/* Originally, logos were stored in monthly folders before they were moved to the theme resources area */
						try
						{
							$this->_data['logo'][ $type ]['url'] = (string) File::get( 'core_Theme', $logo['filename'] )->url;
						}
						catch ( Exception $e ) { }
					}
					else
					{
						$this->_data['logo'][ $type ]['url'] = (string) $this->resource( 'custom/' . $logo['filename'], 'core', 'front' );
					}
                }
            }

            if( $update )
            {
                $this->logo_data = json_encode( $this->_data['logo'] );
                $this->save();
            }
        }

        return $this->_data['logo'] ?? [];
    }
	
	/**
	 * Save resource map
	 *
	 * @param	$value        array    Value to save
	 * @return void
	 */
	public function set_resource_map( array $value ) : void
	{
		$this->_data['resource_map'] = json_encode( $value );

		if ( $this->id === 0 )
		{
			Store::i()->acp_resource_map = $value;
		}
	}
	
	/**
	 * Save CSS map
	 *
	 * @param	$value        array    Value to save
	 */
	public function set_css_map( array $value ) : void
	{
		$this->_data['css_map'] = json_encode( $value );

		if ( $this->id === 0 )
		{
			Settings::i()->changeValues( array( 'acp_css_map' => json_encode( $value ) ) );
		}
	}

	/**
	 * Make sure we clean out the custom CSS to prevent anyone adding closing style tags
	 *
	 * @param string $value
	 * @return void
	 */
	public function set_custom_css( string $value ) : void
	{
		$value = str_replace( '</style>', '', $value );
		$this->_data['custom_css'] = $value;

		$key = $this->_id . '_theme_editor_custom_css';
		if ( isset( Store::i()->$key ) )
		{
			unset( Store::i()->$key );
		}
	}

	/**
	 * Clear any compiled JS when we save
	 *
	 * @param string $value
	 * @return void
	 */
	public function set_core_js( string $value ) : void
	{
		$this->_data['core_js'] = $value;
		$key = $this->_id . '_theme_editor_custom_js';
		if( isset( Store::i()->$key ) )
		{
			unset( Store::i()->$key );
		}
	}

	/**
	 * Return the JS, compiled, from the cache
	 *
	 * @return string
	 */
	public function getCustomJsForOutput() : string
	{
		$key = $this->_id . '_theme_editor_custom_js';
		if ( ! isset( Store::i()->$key ) )
		{
			/* If we didn't specify a function name, create one */
			$functionName = $functionName ?? "js_" . uniqid();

			static::makeProcessFunction( static::fixResourceTags( (string) $this->core_js, 'front' ), $functionName, '', false, true );

			$fqFunc		= 'IPS\\Theme\\'. $functionName;
			$content = $fqFunc();

			/* Replace any <fileStore.xxx> tags in the CSS */
			Output::i()->parseFileObjectUrls( $content );
			Store::i()->$key = $content;
		}

		return Store::i()->$key;
	}

	/**
	 * Return the CSS, compiled, from the cache
	 *
	 * @return string
	 */
	public function getCustomCssForOutput(): string
	{
		/* Use pending changes, if we have any */
		if( $data = $this->editingSessionData() )
		{
			/* Compile the pending CSS but don't store it */
			return static::compileCustomCss( $data['custom_css'] ?? '' );
		}

		if( !$this->custom_css )
		{
			return '';
		}

		$key = $this->_id . '_theme_editor_custom_css';
		if ( ! isset( Store::i()->$key ) )
		{
			Store::i()->$key = static::compileCustomCss( (string) $this->custom_css );
		}

		return Store::i()->$key;
	}

	/**
	 * Compile custom CSS for output
	 *
	 * @param string $content
	 * @param string|null $functionName
	 * @return string
	 */
	public static function compileCustomCss( string $content, ?string $functionName=null ) : string
	{
		/* If we didn't specify a function name, create one */
		$functionName = $functionName ?? "css_" . uniqid();

		static::makeProcessFunction( static::fixResourceTags( $content, 'front' ), $functionName, '', FALSE, TRUE );

		$fqFunc		= 'IPS\\Theme\\'. $functionName;
		$content	= static::minifyCss( $fqFunc() );

		/* Replace any <fileStore.xxx> tags in the CSS */
		Output::i()->parseFileObjectUrls( $content );

		return $content;
	}

	/**
	 * Get the source CSS that is compiled then returned in getCustomCssForOutput
	 *
	 * @return string
	 */
	public function getCustomCssForThemeEditorCodebox(): string
	{
		if( $data = $this->editingSessionData() )
		{
			return $data['custom_css'] ?? '';
		}

		return (string) $this->custom_css;
	}

	/**
	 * Add in the header and footer special tags if they are not already present in globalTemplate
	 *
	 * @return void
	 */
	public function fixHeaderAndFooterTags(): void
	{
		/* Check the globalTemplate for the special tags we need */
		try
		{
			$template = Db::i()->select( '*', 'core_theme_templates', [ 'template_set_id=? AND template_app=? AND template_group=? AND template_location=? and template_name=?', $this->_id, 'core', 'global', 'front', 'globalTemplate'] )->first();
			$update = FALSE;

			if ( ! stristr( $template['template_content'], '{theme="headerHtml"}') )
			{
				$template['template_content'] = preg_replace( '#(<body.*>\n)#i', "$1\n{theme=\"headerHtml\"}\n", $template['template_content'] );
				$update = TRUE;
			}

			if ( ! stristr( $template['template_content'], '{theme="footerHtml"}') )
			{
				$template['template_content'] = preg_replace( '#(</body>\n)#i', "{theme=\"footerHtml\"}\n$1", $template['template_content'] );
				$update = TRUE;
			}

			if ( $update )
			{
				Db::i()->update( 'core_theme_templates', [ 'template_content' => $template['template_content'] ], [ 'template_id=?', $template['template_id'] ] );
			}

		}
		catch( UnderflowException $e )
		{
			/* No custom template, so we're good */
		}
	}
	
	/**
	 * Save Changed Columns
	 *
	 * @return    void
	 */
	public function save(): void
	{
		parent::save();
		unset( Store::i()->themes );

		/* If we are using a customized globalTemplate, we need to slap in the header/footer tags */
		if ( ! empty( $this->custom_footer ) or ! empty( $this->customer_header ) )
		{
			$this->fixHeaderAndFooterTags();
		}

		foreach( [ 'header', 'footer' ] as $type )
		{
			$key = 'custom_' . $type . '_' . $this->_id;
			if ( isset( Store::i()->$key ) )
			{
				unset( Store::i()->$key );
			}
		}

		/* Reset map arrays */
		if ( !isset( $this->_data['resource_map'] ) OR  ! is_array( $this->_data['resource_map'] ) )
		{
			if ( isset( $this->_data['resource_map'] ) AND $imgMap = json_decode( $this->_data['resource_map'], true ) )
			{
				$this->_data['resource_map'] = $imgMap;
			}
			else
			{
				$this->_data['resource_map'] = array();
			}
		}

		if ( !isset( $this->_data['css_map'] ) OR ! is_array( $this->_data['css_map'] ) )
		{
			if ( isset( $this->_data['css_map'] ) AND $cssMap = json_decode( $this->_data['css_map'], true ) )
			{
				$this->_data['css_map'] = $cssMap;
			}
			else
			{
				$this->_data['css_map'] = array();
			}
		}
	}
	
	/*! Node */

	/**
	 * Get header logo
	 *
	 * @return    Url|string
	 */
	public function get_logo_front(): Url|string
	{		
		return $this->logoImage( 'front' );
	}

	/**
	 * Return logo image
	 *
	 * @param string $type	Type of logo image
	 * @return    Url|string
	 */
	protected function logoImage( string $type ): Url|string
	{
		if( !empty( $this->logo[ $type ]['url'] ) )
		{
			try
			{
				return File::get( 'core_Theme', $this->logo[ $type ]['url'] )->url;
			}
			catch( Exception $e )
			{
				return '';
			}	
		}

		return '';
	}

	/**
	 * [Node] Get Description
	 *
	 * @return	string|null
	 */
	protected function get__description(): ?string
	{		
		return Theme::i()->getTemplate( 'customization', 'core' )->themeDescription( $this );
	}

	/**
	 * Get the authors website
	 *	 *
	 * @return Url|null
	 */
	public function website(): ?Url
	{
		if ( $this->_data['author_url'] )
		{
			return Url::createFromString( $this->_data['author_url'] );
		}
		return NULL;
	}

	/**
	 * These variables are hard-coded into the theme editor, even though
	 * they are not defined in the core_theme_editor_settings table. We need
	 * to allow these in @see getInlineCssVariables()
	 *
	 * @var array|string[]
	 */
	public static array $allowedNonSettingVariables = [ 'primary', 'primary-relative-l', 'secondary', 'secondary-relative-l', 'base', 'base-c', 'base-l', 'base-h' ];

	/**
	 * Store the default so that we can determine if this is customized or not
	 *
	 * @var array[]
	 */
	public static array $defaultThemeEditorData = [
		'header' => [
			'logo' => 4,
			'navigation' => 5,
			'user' => 6,
			'breadcrumb' => 7,
			'search' => 9,
		]
	];

	/**
	 * @return string|null
	 */
	public function get_core_css_filename() : ?string
	{
		if( isset( $this->_data['core_css_filename'] ) )
		{
			return $this->_data['core_css_filename'];
		}

		/* If it's empty, check if we have anything custom and need to rebuild */
		if( $this->core_css )
		{
			$this->core_css_filename = File::create( 'core_Theme', 'theme.css', static::compileCustomCss( $this->core_css ) );
			$this->save();
			return $this->_data['core_css_filename'];
		}

		return null;
	}

	/**
	 * @return array
	 */
	public function get_theme_editor_data() : array
	{
		$editorData = isset( $this->_data['theme_editor_data'] ) ? json_decode( $this->_data['theme_editor_data'], true ) : [];
		if( !isset( $editorData['header'] ) )
		{
			$editorData['header'] = static::$defaultThemeEditorData['header'];
		}
		return $editorData;
	}

	/**
	 * @param mixed $val
	 * @return void
	 */
	public function set_theme_editor_data( mixed $val ) : void
	{
		$this->_data['theme_editor_data'] = is_array( $val ) ? json_encode( $val ) : null;
	}

	/**
	 * [Node] Return the custom badge for each row
	 *
	 * @return	NULL|array		Null for no badge, or an array of badge data (0 => CSS class type, 1 => language string, 2 => optional raw HTML to show instead of language string)
	 */
	protected function get__badge(): ?array
	{
		/* Is there an update to show? */
		$badge	= NULL;

		if ( $this->is_default )
		{
			$message = 'default_no_parenthesis';

			$badge	= array(
				0	=> 'positive',
				1	=> $message
			);
		}

		if ( $this->update_data )
		{
			$data	= json_decode( $this->update_data, TRUE );

			if( !empty($data['longversion']) AND $data['longversion'] > $this->long_version )
			{
				$released	= NULL;

				if( $data['released'] AND intval($data['released']) == $data['released'] AND strlen($data['released']) == 10 )
				{
					$released	= DateTime::ts( $data['released'] )->localeDate();
				}
				else if( $data['released'] )
				{
					$released	= $data['released'];
				}

				$badge	= array(
						0	=> 'new',
						1	=> '',
						2	=> Theme::i()->getTemplate( 'global', 'core' )->updatebadge( $data['version'], $data['updateurl'], $released )
				);
			}
		}

		return $badge;
	}

	/**
	 * [Node] Clone the theme set
	 *
	 * @return	void
	 */
	public function __clone()
	{
		if( $this->skipCloneDuplication === TRUE )
		{
			return;
		}

		$originalId = $this->_data['id'];
		$title      = Member::loggedIn()->language()->get( static::$titleLangPrefix . $originalId );
		
		/* Unset custom properties */
		foreach( array( 'resource_map', 'css_map', 'logo', 'name_translated', 'title' ) as $f )
		{
			unset( $this->_data[ $f ] );
		}
		$this->is_default = FALSE;
		
		parent::__clone();
		
		/* Insert new language bit */
		Lang::saveCustom( 'core', "core_theme_set_title_" . $this->id, sprintf( Member::loggedIn()->language()->get( 'theme_clone_copy_of' ), $title ) );
		
		/* Make sure data objects are loaded correctly */
		static::$gotAll = false;

		/* Save css/img maps */
		Theme::load( $this->id )->saveSet();
		
		Session::i()->log( 'acplogs__themeset_created', array( sprintf( Member::loggedIn()->language()->get( 'theme_clone_copy_of' ), Member::loggedIn()->language()->get( 'core_theme_set_title_' . $originalId ) ) => FALSE ) );
	}

	/**
	 * [Node] Does the currently logged-in user have permission to delete this node?
	 *
	 * @return    bool
	 */
	public function canDelete(): bool
	{
		if ( IN_DEV AND $this->_id == DEFAULT_THEME_ID)
		{
			return FALSE;
		}

		if( $this->is_default )
		{
			return FALSE;
		}

		foreach( $this->children( NULL ) as $childTheme )
		{
			if( $childTheme->is_default )
			{
				return FALSE;
			}
		}

		return parent::canDelete();
	}
	
	/**
	 * [Node] Delete the theme set
	 *
	 * @return    void
	 */
	public function delete(): void
	{
		if ( IN_DEV AND $this->_id == DEFAULT_THEME_ID)
		{
			Output::i()->error( 'theme_error_not_available_in_dev', '2S140/1', 403, '' );
		}
		
		if ( $this->is_default )
		{
			Output::i()->error( 'core_theme_cannot_delete_default_theme', '2S162/1', 403, '' );
		}
		
		/* Clear out existing built bits */
		File::getClass('core_Theme')->deleteContainer( 'css_built_' . $this->_id );
		File::getClass('core_Theme')->deleteContainer( 'set_resources_' . $this->_id );
		
		$templates = $this->getAllTemplates();
			
		foreach( $templates as $app => $v )
		{
			foreach( $v as $location => $groups )
			{
				foreach( $v[ $location ] as $group => $bits )
				{
					foreach( $v[ $location ][ $group ] as $name => $data )
					{
						/* Store it */
						$key = strtolower( 'template_' . $this->_id . '_' .static::makeBuiltTemplateLookupHash( $app, $location, $group ) . '_' . static::cleanGroupName( $group ) );
							
						unset( Store::i()->$key );
					}
				}
			}
		}
		
		Db::i()->delete( 'core_theme_resources', array( 'resource_set_id=?', $this->_id ) );
		Db::i()->delete( 'core_theme_css', array( 'css_set_id=?', $this->_id ) );
		Db::i()->delete( 'core_theme_templates', array( 'template_set_id=?', $this->_id ) );
		Db::i()->delete( 'core_sys_lang_words', array( 'word_theme=?', $this->_id ) );
		Db::i()->delete( 'core_theme_templates_custom', [ 'template_set_id=?', $this->_id ] );

		/* Delete theme editor settings */
		Db::i()->delete( 'core_theme_editor_settings', [ 'setting_set_id=?', $this->_id ] );

		/* Check the categories. If there are settings from another app, then we leave the category,
		and change the application key */
		$categories = iterator_to_array(
			Db::i()->select( '*', 'core_theme_editor_categories', [ 'cat_set_id=?', $this->_id ] )
		);

		foreach( $categories as $category )
		{
			try
			{
				$otherSetting = Db::i()->select( '*', 'core_theme_editor_settings', [ 'setting_category_id=? and setting_set_id != ?', $category['cat_id'], $this->_id ], 'setting_position', array( 0, 1 ) )->first();
				Db::i()->update( 'core_theme_editor_categories', [ 'cat_set_id' => 0 ], [ 'cat_id=?', $category['cat_id'] ] );
			}
			catch( UnderflowException )
			{
				Db::i()->delete( 'core_theme_editor_categories', [ 'cat_id=?', $category['cat_id'] ] );
			}
		}

		/** reset member skin  */
		Db::i()->update( 'core_members', array( 'skin' => 0 ), array('skin=?', $this->_id ) );

		parent::delete();
		unset( Store::i()->themes );
	}

	/**
	 * [Node] Add/Edit Form
	 *
	 * @param Form $form The form
	 * @return    void
	 * @throws Exception
	 */
	public function form( Form &$form ): void
	{
		/* Check permission */
		Dispatcher::i()->checkAcpPermission( 'theme_sets_manage' );
		
		Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'customization/themes.css', 'core', 'admin' ) );
		Output::i()->jsFiles  = array_merge( Output::i()->jsFiles, Output::i()->js( 'admin_customization.js', 'core', 'admin' ) );
		$form->attributes[ 'data-controller' ] = 'core.admin.customization.themeForm';

		/* General */
		if ( $this->id )
		{
			Output::i()->title = Member::loggedIn()->language()->addToStack('core_theme_editing_set', FALSE, array( 'sprintf' => array( $this->_title ) ) );
			
			$form->addTab( 'theme_set_tab__general' );

			$groups = array();
			foreach( Group::groups() as $group )
			{
				if ( $group->g_bitoptions['gbw_change_layouts'] )
				{
					$groups[] = Theme::i()->getTemplate( 'customization' )->groupLink( $group->g_id, $group->name );
				}
			}

			$form->addHtml( Theme::i()->getTemplate( 'customization' )->layoutPermissionBlurb( $groups ) );
		}
		else
		{
			Output::i()->title = Member::loggedIn()->language()->addToStack('theme_set_add_button');
			$form->addTab( 'theme_set_tab__new_custom_set' );
		}
		
		$form->add( new Translatable( 'core_theme_set_title', NULL, TRUE, array( 'app' => 'core', 'key' => ( $this->id ? "core_theme_set_title_{$this->id}" : NULL ) ) ) );

		$id = $this->id;
		$form->add( new YesNo( 'core_theme_set_is_default' , $this->is_default, false, array( 'togglesOff'  => array('core_theme_set_permissions') ), function( $val ) use ( $id )
		{
			$where = array( array( 'set_is_default=1' ) );
			if ( $id )
			{
				$where[] = array('set_id<>?', $id );
			}
			
			if ( !$val and !Db::i()->select( 'COUNT(*)', 'core_themes', $where )->first() )
			{
				throw new DomainException('core_theme_set_is_default_error');
			}
		} ) );
		
		$form->add( new CheckboxSet(
				'core_theme_set_permissions',
				( $this->id ) ? ( $this->permissions === '*' ? '*' : explode( ",", $this->permissions ) ) : '*',
				FALSE,
				array( 'options' => Group::groups(), 'multiple' => TRUE, 'parse' => 'normal', 'unlimited' => '*', 'unlimitedLang' => 'all', 'impliedUnlimited' => TRUE ),
				NULL,
				NULL,
				NULL,
				'core_theme_set_permissions'
		) );
				
		if (IN_DEV)
		{
			$form->add( new Text( 'theme_template_export_author_name', $this->author_name, false ) );
			$form->add( new Text( 'theme_template_export_author_url' , $this->author_url, false ) );
			$form->add( new Text( 'theme_update_check' , $this->update_check, false ) );
				
			$form->add( new Text( 'theme_template_export_version'        , $this->version ? $this->version : '1.0'    , true, array( 'placeholder' => '1.0.0' ) ) );
			$form->add( new Number( 'theme_template_export_long_version' , $this->long_version ?: 10000, true ) );
		}

		/* Header, footer and custom CSS */
		if ( Member::loggedIn()->hasAcpRestriction( 'core', 'customization', 'theme_templates_manage' ) )
		{
			$form->addTab('core_theme_set_header_and_footer_header');
			$form->add( new Codemirror( 'theme_simple_header_settings', $this->custom_header, FALSE, [ 'codeModeAllowedLanguages' => [ 'ipsphtml' ], 'height' => 600 ], function( $val ) {
				try
				{
					static::makeProcessFunction( $val, 'template_' . md5( mt_rand() . time() ), '', TRUE, FALSE );
				}
				catch( ParseError $e )
				{
					throw new DomainException( 'core_theme_template_parse_error' );
				}
			}, NULL, NULL, 'theme_header' ) );
			$form->add( new Codemirror( 'theme_simple_footer_settings', $this->custom_footer, FALSE, [ 'codeModeAllowedLanguages' => [ 'ipsphtml' ], 'height' => 600 ], function( $val ) {
				try
				{
					static::makeProcessFunction( $val, 'template_' . md5( mt_rand() . time() ), '', TRUE, FALSE );
				}
				catch( ParseError $e )
				{
					throw new DomainException( 'core_theme_template_parse_error' );
				}
			}, NULL, NULL, 'theme_footer' ) );
		}

		$form->canSaveAndReload = true;
	}

	/**
	 * [Node] Get buttons to display in tree
	 * Example code explains return value
	 *
	 * @param Url $url		Base URL
	 * @param bool $subnode	Is this a subnode?
	 * @return    array
	 */
	public function getButtons( Url $url, bool $subnode=FALSE ): array
	{
		$parentButtons = array();
		$buttons       = array();

		foreach( parent::getButtons( $url, $subnode ) as $button )
		{
			$parentButtons[ $button['title'] ] = $button;
		}

		unset( $parentButtons['edit']['data']['ipsDialog'], $parentButtons['edit']['data']['ipsDialog-title'] );

		$buttons['edit'] = $parentButtons['edit'];

		$buttons['resources'] = array(
			'icon'	=> 'file-image',
			'title'	=> 'theme_set_manage_resources',
			'link'	=> Url::internal( "app=core&module=customization&controller=media&set_id={$this->_id}" ),
			'data'	=> array()
		);

		$buttons['copy'] = $parentButtons['copy'];

		if ( Member::loggedIn()->hasAcpRestriction( 'core', 'customization', 'theme_download_upload' ) AND !DEMO_MODE)
		{
			$buttons['upload'] = array(
					'icon' => 'upload',
					'title' => 'theme_set_import',
					'link' => Url::internal( "app=core&module=customization&controller=themes&do=importForm&id={$this->_id}" ),
					'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack( 'theme_set_import_title', FALSE, array( 'sprintf' => array( $this->_title ) ) ) )
			);

			$buttons['download'] = array(
					'icon'	=> 'download',
					'title'	=> 'theme_set_export',
					'link'	=> Url::internal( "app=core&module=customization&controller=themes&do=exportForm&id={$this->_id}" . ( ( IN_DEV or Settings::i()->theme_designer_mode ) ? '' : '&form_submitted=1' ) )
			);
		}

		if ( Member::loggedIn()->hasAcpRestriction( 'core', 'members', 'member_edit' ) )
		{
			$buttons['member_theme_set'] = array(
					'icon'	=> 'user',
					'title'	=> 'theme_set_members',
					'link'	=> $url->setQueryString( array( 'do' => 'setMembers', 'id' => $this->is_default ? 0 : $this->_id ) ),
					'data' 	=> array( 'ipsDialog' => '', 'ipsDialog-title' => $this->_title )
			);
		}

		if ( $this->canDelete() )
		{
			$buttons['delete'] = $parentButtons['delete'];
		}

		if ( $this->isCustomized() )
		{
			$buttons['theme_revert'] = [
				'icon' => 'reply',
				'title' => 'theme_set_revert',
				'link' => Url::internal( "app=core&module=customization&controller=themes&do=revertCustomizations&set_id={$this->_id}" ),
				'data' => [ 'confirm' => 'true', 'confirmMessage' => Member::loggedIn()->language()->addToStack( 'theme_set_revert_explain' )]
			];
		}

		if ( IN_DEV or Settings::i()->theme_designer_mode )
		{
			$buttons['theme_settings'] = [
				'icon' => 'sliders',
				'title' => 'theme_set_editor_settings',
				'link' => Url::internal( "app=core&module=customization&controller=themeeditor&set_id={$this->_id}" )
			];

			$buttons['theme_core_cssjs'] = [
				'icon' => 'file-code',
				'title' => 'theme_set_designer_core_cssjs',
				'link' => Url::internal( "app=core&module=customization&controller=themes&do=designerCore&set_id={$this->_id}" )
			];

			$buttons['custom_templates'] = array(
				'icon'	=> 'code',
				'title'	=> 'theme_set_manage_templates_custom',
				'link'	=> Url::internal( "app=core&module=customization&controller=customtemplates&type=themes&set_id={$this->_id}" ),
				'data'	=> array()
			);
		}

		return $buttons;
	}

	/**
	 * [Node] Save Add/Edit Form
	 *
	 * @param	array	$values	Values from the form
	 * @return    mixed
	 */
	public function saveForm( array $values ): mixed
	{
		$creating = FALSE;

		/* Create if necessary */
		if ( ! $this->id )
		{
			$creating = TRUE;
			$this->long_version = Application::load( 'core' )->long_version;
			$this->save();
		}
		
		if ( isset( $values['core_theme_set_new_import'] ) )
		{
			/* Move it to a temporary location */
			$tempFile = tempnam( TEMP_DIRECTORY, 'IPS' );
			move_uploaded_file( $values['core_theme_set_new_import'], $tempFile );
			
			/* Store values */
			$key = 'core_theme_import_' . md5_file( $tempFile );
			Store::i()->$key = array( 'apps'  		=> 'all',
										        'resources'		=> true,
										        'html'			=> true,
										        'css'			=> true );
			
			/* Initate a redirector */
			Output::i()->redirect( Url::internal( 'app=core&module=customization&controller=themes&do=import' )->setQueryString( array( 'file' => $tempFile, 'key' => md5_file( $tempFile ), 'id' => $this->id ) ) );
		}
		else
		{
			/* Name */
			Lang::saveCustom( 'core', "core_theme_set_title_{$this->id}", $values['core_theme_set_title'] );
			
			$dataChanged = false;
			$save        = array();
			
			if ( $values['core_theme_set_is_default'] )
			{
				Db::i()->update( 'core_themes', array( 'set_is_default' => 0 ), array( 'set_id<>?', $this->id ) );
				$dataChanged = true;
			}
			
			if ( $dataChanged OR IN_DEV)
			{
				if (IN_DEV)
				{
					$save['set_author_name']  = $values['theme_template_export_author_name'];
					$save['set_author_url']   = $values['theme_template_export_author_url'];
					$save['set_update_check'] = $values['theme_update_check'];
					$save['set_version']      = $values['theme_template_export_version'];
					$save['set_long_version'] = $values['theme_template_export_long_version'];
				}
				$this->save();
				$this->saveSet( $save );
			}
		}

		$this->is_default  = $values['core_theme_set_is_default'];
		$this->permissions = ( $values['core_theme_set_permissions'] === '*' ) ? '*' : implode( ',', $values['core_theme_set_permissions'] );
		$this->css_updated = time();

		if ( isset( $values['theme_simple_header_settings'] ) )
		{
			$this->custom_header = $values['theme_simple_header_settings'];
		}

		if ( isset( $values['theme_simple_footer_settings'] ) )
		{
			$this->custom_footer = $values['theme_simple_footer_settings'];
		}

		if ( isset( $values['theme_simple_css'] ) )
		{
			$this->custom_css = $values['theme_simple_css'];
		}

		$headerKey = 'custom_header_' . $this->id;
		$footerKey = 'custom_footer_' . $this->id;
		unset( Store::i()->$headerKey);
		unset( Store::i()->$footerKey);

		/* Remove compiled templates to ensure the globalTemplate gets the latest updates */
		static::deleteCompiledTemplate( 'core', 'front', 'global', $this->id );

		/* Remove compiled CSS */
		static::deleteCompiledCss( 'core', 'front', 'custom', 'custom.css', $this->id );

		/* Finally, save the theme */
		$this->save();

		return NULL;
	}

	/**
	 * Return the value of a layout setting
	 *
	 * @param string $what
	 * @return string
	 * @throws InvalidArgumentException
	 */
	public function getLayoutValue( string $what ): string
	{
		/* Check the session data first */
		$sessionData = $this->editingSessionData()['data'] ?? [];

		/* For consistency, the theme editor prefixes layout options with 'layout_' */
		if( isset( $sessionData['layouts'] ) and isset( $sessionData['layouts'][ 'layout_' . $what ] ) )
		{
			return $sessionData['layouts'][ 'layout_' . $what ];
		}
		elseif( isset( $sessionData['vars'] ) and isset( $sessionData['vars'][ 'layout_' . $what ] ) )
		{
			return $sessionData['vars'][ 'layout_' . $what ];
		}

		if ( empty( $this->_layoutValues ) )
		{
			$this->_layoutValues = $this->view_options ? json_decode( $this->view_options, TRUE ) : [];
		}

		if ( isset( $this->_layoutValues[ $what ] ) )
		{
			return $this->_layoutValues[ $what ];
		}

		/* Got a default? */
		$vars = $this->getCssVariables();
		if( isset( $vars[ 'layout_' . $what ] ) )
		{
			return $vars[ 'layout_' . $what ];
		}

		/* Can't really help you */
		throw new InvalidArgumentException( 'Invalid layout value' );
	}

	/**
	 * Return the available layout options
	 *
	 * @return array
	 */
	public function getAvailableLayoutOptionsForThemeEditor(): array
	{
		$return = [];

		foreach( Application::enabledApplications() as $dir => $app )
		{
			$return = array_merge( $return, $app->getThemeLayoutOptionsForThisPage() );
		}
		
		/* loop through $return array and prepend 'layout_' to each value */
		foreach ( $return as &$value )
		{
			$value = 'layout_' . $value;
		}
		
		return $return;
	}

	/**
	 * Build resource map of "human URL" to File Object URL
	 *
	 * @param array|string|null $app	App (e.g. core, forum)
	 * @return	void
	 */
	public function buildResourceMap( array|string $app=NULL )
	{
		$flagKey = 'resource_compiling_' . $this->_id . '_' . $app;
		if ( static::checkLock( $flagKey ) )
		{
			return NULL;
		}

		static::lock( $flagKey );
		
		$resourceMap = $this->resource_map;
	
		$where = ( $app !== null ) ? array( 'resource_set_id=? and resource_app=?', $this->_id, $app ) : array('resource_set_id=?', $this->_id );

		foreach (Db::i()->select( '*', 'core_theme_resources', $where ) as $row )
		{
			$name = static::makeBuiltTemplateLookupHash( $row['resource_app'], $row['resource_location'], $row['resource_path'] ) . '_' . $row['resource_name'];

			if ( $row['resource_filename'] )
			{
				$resourceMap[$name] = $row['resource_filename'];
			}
			else
			{
				/* If there is no filename, then it has yet to be compiled so do not add it to the resource map as it prevents it being compiled later */
				unset( $resourceMap[$name] );
			}
		}

		$this->resource_map = $resourceMap;

        /* Any time we rebuild the resources, we need to rebuild the logos */
        $logoData = isset( $this->_data['logo_data'] ) ? json_decode( $this->_data['logo_data'], true ) : [];
        foreach( $logoData as $type => $logo )
        {
            if( isset( $logo['url'] ) )
            {
                unset( $logoData[ $type ]['url'] );
            }
        }
        $this->logo_data = json_encode( $logoData );

		$this->save();
		
		static::unlock( $flagKey );
	}

	/**
	 * Copy all resources from set $id to this set
	 * Theme resources should be raw binary data everywhere (filesystem and DB) except in the theme XML download where they are base64 encoded.
	 *
	 * @param int $id		ID to copy from. 0 is the 'master' resources (same as the default when first installed)
	 * @param int $offset
	 * @param int|null $limit
	 * @return  void
	 */
	public function copyResourcesFromSet( int $id=0, int $offset=0, ?int $limit=null ): void
	{
		$resourceMap = $this->resource_map ?? [];
		if( !is_array( $resourceMap ) )
		{
			$resourceMap = json_decode( $resourceMap, true );
		}

		if( $limit !== null )
		{
			$limit = [ $offset, $limit ];
		}
		
		foreach (Db::i()->select( '*', 'core_theme_resources', array( 'resource_set_id=?', $id ), 'resource_id', $limit ) as $data )
		{
			$key = static::makeBuiltTemplateLookupHash($data['resource_app'], $data['resource_location'], $data['resource_path']) . '_' . $data['resource_name'];
			
			if ( $data['resource_data'] )
			{
				try
				{
					$fileName = (string) File::create( 'core_Theme', $key, $data['resource_data'], 'set_resources_' . $this->_id, FALSE, NULL, FALSE );
				
					Db::i()->insert( 'core_theme_resources', array(
							'resource_set_id'      => $this->_id,
							'resource_app'         => $data['resource_app'],
							'resource_location'    => $data['resource_location'],
							'resource_path'        => $data['resource_path'],
							'resource_name'        => $data['resource_name'],
							'resource_added'	   => time(),
							'resource_filename'    => $fileName,
							'resource_data'        => $data['resource_data'],
							'resource_user_edited' => $data['resource_user_edited']
					) );
					
					$resourceMap[ $key ] = $fileName;
				}
				catch( Exception $e ) { }
			}
		}
		
		/* Update theme map */
		$this->resource_map = $resourceMap;
		$this->save();
	}
	
	/**
	 * Copy all logos from a set
	 *
	 * @param int $id			ID to copy from
	 * @return  void
	 */
	public function copyLogosFromSet( int $id ): void
	{
		try
		{
			$original = static::load( $id );
		}
		catch( OutOfRangeException $e )
		{
			throw new OutOfRangeException("CANNOT_LOAD_THEME");
		}
		
		if ( $original->logo_data === NULL )
		{
			return;
		}

		/* Allow SVGs without the obscure hash removing the file extension */
		File::$safeFileExtensions[] = 'svg';
		
		$currentLogos = json_decode( $this->logo_data, TRUE );
		$logos        = array();
		foreach ( json_decode( $original->logo_data, TRUE ) as $file => $data )
		{
			if ( isset( $currentLogos[ $file ] ) )
			{
				continue;
			}
			
			if( isset( $data['url'] ) and $data['url'] )
			{
				try
				{
					/* Create new file */
					$original = File::get( 'core_Theme', $data['url'] );
					$newImage = File::create( 'core_Theme', $original->originalFilename, $original->contents() );

					$data['url'] = (string) $newImage;
                    $data['filename'] = $newImage->originalFilename;
					$logos[$file] = $data;
				}
				catch ( Exception $e ) {}
			}
		}
		
		$this->logo_data = json_encode( $logos );
		$this->save();
	}

	/**
	 * Copy the editor settings and categories from the specified theme
	 *
	 * @param int $id
	 * @return void
	 */
	public function copyEditorSettings( int $id ) : void
	{
		$categoryMapping = [];
		foreach( new ActiveRecordIterator(
			Db::i()->select( '*', Category::$databaseTable, [ 'cat_set_id=?', $id ], 'cat_parent, cat_position' ),
			Category::class
				 ) as $cat )
		{
			/* Copy the category and change the theme ID */
			$newCategory = clone $cat;
			$newCategory->set_id = $this->_id;
			if( $newCategory->parent and array_key_exists( $cat->parent, $categoryMapping ) )
			{
				$newCategory->parent = $categoryMapping[ $cat->parent ];
			}
			$newCategory->save();

			/* Map the category IDs, we'll need them later */
			$categoryMapping[ $cat->id ] = $newCategory->id;
		}

		/* Now loop through the settings */
		foreach( new ActiveRecordIterator(
			Db::i()->select( '*', Setting::$databaseTable, [ 'setting_set_id=?', $id ], 'setting_position' ),
			Setting::class
				 ) as $setting )
		{
			$newSetting = clone $setting;
			$newSetting->set_id = $this->_id;
			$newSetting->category_id = $categoryMapping[ $newSetting->category_id ] ?? 0;
			$newSetting->save();
		}
	}

	/**
	 * Copy any custom templates from the original theme
	 *
	 * @param int $id
	 * @return void
	 */
	public function copyCustomTemplates( int $id ) : void
	{
		foreach( new ActiveRecordIterator(
			Db::i()->select( '*', 'core_theme_templates_custom', [ 'template_set_id=?', $id ] ),
			CustomTemplate::class
				 ) as $template )
		{
			$newTemplate = clone $template;
			$newTemplate->set_id = $this->id;
			$newTemplate->updated = time();
			$newTemplate->save();
		}
	}

	/**
	 * Install the theme editor settings for this theme
	 *
	 * @return void
	 */
	public function installThemeEditorSettings() : void
	{

	}
	
	/**
	 * Compile all CSS for a first party application
	 *
	 * @param string $app
	 * @return void
	 */
	public static function compileStatic( string $app='' ) : void
	{
		if( !in_array( $app, IPS::$ipsApps ) )
		{
			return;
		}

		$themeXmlFile = Application::load( $app )->getApplicationPath() . "/data/theme.xml";
		if( ! file_exists( $themeXmlFile ) )
		{
			return;
		}

		/* Try to prevent timeouts to the extent possible */
		$cutOff			= null;

		if( $maxExecution = @ini_get( 'max_execution_time' ) )
		{
			/* If max_execution_time is set to "no limit" we should add a hard limit to prevent browser timeouts */
			if ( $maxExecution == -1 )
			{
				$maxExecution = 30;
			}

			$cutOff	= time() + ( $maxExecution * .5 );
		}

		/* Remove old templates... */
		$files = glob( ROOT_PATH . '/static/templates/' . $app . '_*' );
		foreach( $files as $file )
		{
			@unlink( $file );
		}

		/* Remove old CSS... */
		$files = glob( ROOT_PATH . '/static/css/' . $app . '_*' );
		foreach( $files as $file )
		{
			@unlink( $file );
		}

		/* Remove old resources... */
		$files = glob( ROOT_PATH . '/static/resources/' . $app . '_*' );
		foreach( $files as $file )
		{
			@unlink( $file );
		}

		/* Open XML file */
		$xml = Xml\XMLReader::safeOpen( $themeXmlFile );
		$xml->read();

		$functions = array();
		$styles = array();
		while( $xml->read() )
		{
			if( $xml->nodeType != XMLReader::ELEMENT )
			{
				continue;
			}

			switch( $xml->name )
			{
				case 'template':
					$template	= array(
						'group'		=> $xml->getAttribute('template_group'),
						'name'		=> $xml->getAttribute('template_name'),
						'variables'	=> $xml->getAttribute('template_data'),
						'content'	=> $xml->readString(),
						'location'	=> $xml->getAttribute('template_location')
					);

					if( !isset( $functions[ $template['location'] ][ $template['group'] ] ) )
					{
						$functions[ $template['location'] ][ $template['group'] ] = [];
					}

					$functions[ $template['location'] ][ $template['group'] ][ $template['name'] ] = static::compileTemplate( $template['content'], $template['name'], $template['variables'], true, false, $app, $template['location'], $template['group'] );
					break;

				case 'css':
					$location = $xml->getAttribute( 'css_location' );
					$path = $xml->getAttribute( 'css_path' );
					$name = $xml->getAttribute( 'css_name' );
					$content = $xml->readString();
					if( !isset( $styles[ $location ][ $path ] ) )
					{
						$styles[ $location ][ $path ] = [];
					}
					$styles[ $location ][ $path ][ $name ] = $content;
					break;

				case 'resource':
					static::addResource( array(
						'app'		=> $app,
						'location'	=> $xml->getAttribute('location'),
						'path'		=> $xml->getAttribute('path'),
						'name'		=> $xml->getAttribute('name'),
						'content'	=> base64_decode( $xml->readString() ),
					) );
					break;
			}
		}

		/* Loop through all functions and write them to the static directory */
		$path = '/static/templates/';
		if( !is_dir( ROOT_PATH. $path ) )
		{
			mkdir( ROOT_PATH . $path, IPS_FOLDER_PERMISSION, TRUE );
		}

		foreach( $functions as $location => $groups )
		{
			foreach( $groups as $group => $templates )
			{
				/* Put them in a class */
				$templateContent = <<<EOF
<?php
namespace IPS\Theme;
class class_{$app}_{$location}_{$group} extends \IPS\Theme\Template
{
EOF;
				$templateContent .= implode( "\n\n", $templates );
				$templateContent .= <<<EOF
}
EOF;

				$templateFileName = $path . $app . '_' . $location. '_' . $group . '.php';
				$result = (bool) @file_put_contents( ROOT_PATH . $templateFileName, $templateContent, LOCK_EX );

				/* Sometimes LOCK_EX is unavailable and throws file_put_contents(): Exclusive locks are not supported for this stream.
					While we would prefer an exclusive lock, it would be better to write the file if possible. */
				if( !$result )
				{
					@unlink( ROOT_PATH . $templateFileName );
					$result = (bool) @file_put_contents( ROOT_PATH . $templateFileName, $templateContent );
				}

				@chmod( ROOT_PATH . $templateFileName, IPS_FILE_PERMISSION );
			}
		}

		/**
		 * Everything that is a single CSS file in the /dev/css area should be a single file unless
		 * you're in the special building rules because oooh I'm so special look at me.
		 */
		foreach( $styles as $location => $paths )
		{
			foreach( $paths as $path => $data )
			{
				if( $path == '.' OR empty( $path ) )
				{
					foreach ( $data as $cssName => $cssData )
					{
						/* forums/front/widgets.css should be written as forums_front_widgets.css */
						static::writeCss( [
							'css_app' => $app,
							'css_location' => $location,
							'css_path' => '.',
							'css_name' => $cssName,
							'css_content' => $cssData,
							'css_set_id' => 0
						] );
					}
				}
				else
				{
					/* Special rules... */
					if ( isset( static::$buildGrouping['css'][ $app ][ $location ] ) and in_array( $path, static::$buildGrouping['css'][ $app ][ $location ] ) )
					{
						/* We want this packaged up as one file */
						static::writeCss( [
							'css_app' => $app,
							'css_location' => $location,
							'css_path' => $path,
							'css_name' => $path . '.css',
							'css_content' => implode( "\n\n", $data ),
							'css_set_id' => 0
						] );
					}
					else
					{
						/* Otherwise we don't (e.g. core/front/styles/foo.css should be written as core_front_styles_foo.css) */
						foreach( $data as $cssName => $cssData )
						{
							static::writeCss( array(
								'css_app' => $app,
								'css_location' => $location,
								'css_path' => $path,
								'css_name' => $cssName,
								'css_content' => $cssData,
								'css_set_id' => 0
							) );
						}
					}
				}
			}
		}
	}

	/**
	 * Compile CSS ready for non IN_DEV use. This replaces any HTML logic such as {resource="foo.png"} with full URLs
	 *
	 * @param array|string $app CSS app (e.g. core, forum)
	 * @param array|string $location CSS location (e.g. admin,global,front)
	 * @param array|string $group CSS group (e.g. custom, framework)
	 * @param string $name CSS name (e.g. foo.css)
	 * @return boolean|null
	 */
	public function compileCss( array|string $app='', array|string $location='', array|string $group='', string $name='' ): ?bool
	{
		$flagKey = 'css_compiling_' . $this->_id . '_' . md5( $app . ',' . $location . ',' . $name );
		if ( static::checkLock( $flagKey ) )
		{
			return NULL;
		}

		static::lock( $flagKey );
		
		/* Deconstruct build grouping */
		if ( $name !== null )
		{
			if ( isset( static::$buildGrouping['css'][ $app ][ $location ] ) )
			{
				foreach( static::$buildGrouping['css'][ $app ][ $location ] as $grouped )
				{
					if ( str_replace( '.css', '', $name ) == $grouped )
					{
						$group = $grouped;
					}
				}
			}
		}

		$css    = $this->getAllCss( $app, $location, $group );
		$cssMap = $this->css_map;

		if ( $name === null )
		{
			/* Clear out existing built bits */
			File::getClass('core_Theme')->deleteContainer( 'css_built_' . $this->_id );
			
			$cssMap = array();
		}

		foreach( $css as $app => $v )
		{
			foreach( $css[ $app ] as $location => $paths )
			{
				$built = array();
	
				foreach( $css[ $app ][ $location ] as $path => $data )
				{
					foreach( $css[ $app ][ $location ][ $path ] as $cssName => $cssData )
					{
						if ( isset( static::$buildGrouping['css'][ $app ][ $location ] ) AND in_array( $path,  static::$buildGrouping['css'][ $app ][ $location ] ) )
						{
							if ( $name === null OR $name == ( $path . '.css' ) )
							{
								$key = static::makeBuiltTemplateLookupHash( $app, $location, $path );
								
								if ( isset( $built[ $key ] ) )
								{
									$built[ $key ]['css_content'] .= "\n\n" . $cssData['css_content'];
								}
								else
								{
									$cssData['css_name'] = $path . '.css';
									$cssData['css_path'] = '.';
		
									$built[ $key ] = $cssData;
								}
							}
						}
						else
						{
							if ( $name === null OR $name == $cssData['css_name'] )
							{
								$store  = static::makeBuiltTemplateLookupHash( $app, $location, $cssData['css_path'] . '/' . $cssData['css_name'] );
					
								$cssMap[ $store ] = (string) static::writeCss( $cssData );
							}
						}
					}
				}
				
				/* Write combined css */
				if ( count( $built ) )
				{
					foreach( $built as $id => $cssData )
					{
						$store = static::makeBuiltTemplateLookupHash( $app, $location, $cssData['css_path'] . '/' . $cssData['css_name'] );

						$cssMap[ $store ] = (string) static::writeCss( $cssData );
					}
				}
			}
		}

		if ( ! empty( trim( $this->custom_css ) ) )
		{
			$store = static::makeBuiltTemplateLookupHash( 'core', 'front', 'custom/custom.css');
			$cssMap[ $store ] = (string) static::writeCss( [
				'css_content' => $this->custom_css,
				'css_name'    => 'custom.css',
				'css_path'    => 'custom',
				'css_location' => 'front',
				'css_app' => 'core',
				'css_set_id' => $this->id
			] );
		}

		$this->css_map = $cssMap;
		$this->save();
	
		static::unlock( $flagKey );

		return TRUE;
	}
	
	/**
	 * Build Templates ready for non IN_DEV use
	 * This fetches all templates in a group, converts HTML logic into ready to eval PHP and stores as a single PHP class per template group
	 *
	 * @param array|string|null $app		Templates app (e.g. core, forum)
	 * @param array|string|null $location	Templates location (e.g. admin,global,front)
	 * @param array|string|null $group		Templates group (e.g. forms, members)
	 * @return	boolean|null
	 */
	public function compileTemplates( array|string $app=null, array|string $location=null, array|string $group=null ): ?bool
	{
		$flagKey = 'template_compiling_' . $this->_id . '_' . md5( $app . ',' . $location . ',' . $group );
		if ( static::checkLock( $flagKey ) )
		{
			return NULL;
		}

		static::lock( $flagKey );

		$templates = $this->getAllTemplates( $app === null ? '' : $app, $location === null ? '' : $location, $group === null ? '' : $group );

		foreach( $templates as $app => $v )
		{
			foreach( $templates[ $app ] as $location => $groups )
			{
				foreach( $templates[ $app ][ $location ] as $group => $bits )
				{
					/* Build all the functions */
					$functions = array();
					foreach( $templates[ $app ][ $location ][ $group ] as $name => $data )
					{
						$functions[ $name ] = static::compileTemplate( $data['template_content'], $name, $data['template_data'], true, false, $app, $location, $group );
					}
				
					/* Put them in a class */
					$template = <<<EOF
namespace _NAMESPACE_;
class class_{$app}_{$location}_{$group} extends \IPS\Theme\Template
{
EOF;
					
					$template .= implode( "\n\n", $functions );
		
					$template .= <<<EOF
}
EOF;

					/* Store it */
					$key = strtolower( 'template_' . $this->_id . '_' .static::makeBuiltTemplateLookupHash( $app, $location, $group ) . '_' . static::cleanGroupName( $group ) );
					Store::i()->$key = str_replace( 'namespace _NAMESPACE_', 'namespace IPS\Theme', $template );
				}
			}
		}

		static::unlock( $flagKey );

		return TRUE;
	}

	/**
	 * Clean the group name
	 *
	 * @param string $name       The name to clean
	 * @return  string
	 */
	public static function cleanGroupName( string $name ): string
	{
		return str_replace( '-', '_', Friendly::seoTitle( $name ) );
	}

	/**
	 * Find templates in a template group
	 *
	 * @param string $group		Template group to search in
	 * @param string|null $app		Application key
	 * @param string|null $location	Template location
	 * @return	array
	 */
	public static function findTemplatesByGroup( string $group, string $app=NULL, string $location=NULL ): array
	{
		if (IN_DEV)
		{
			return Theme::findTemplatesByGroup( $group, $app, $location );
		}

		$where	= array( array( 'template_group=?', $group ) );

		if( $app !== NULL )
		{
			$where[]	= array( 'template_app=?', $app );
		}

		if( $location !== NULL )
		{
			$where[]	= array( 'template_location=?', $location );
		}

		$results	= array();

		foreach(Db::i()->select( 'template_name', 'core_theme_templates', $where ) as $result )
		{
			$results[ $result['template_name'] ]	= $result['template_name'];
		}

		return array_unique( $results );
	}
	
	/**
	 * [Node] Does the currently logged in user have permission to edit permissions for this node?
	 *
	 * @return	bool
	 */
	public function canManagePermissions(): bool
	{
		return false;
	}

	/**
	 * User can access this theme?
	 *
	 * @return	bool
	 */
	public function canAccess(): bool
	{
		if ( $this->is_default )
		{
			return true;
		}

		return ( $this->permissions === '*' OR Member::loggedIn()->inGroup( explode( ',', $this->permissions ) ) );
	}

	/**
	 * Get theme header and footer
	 *
	 * @param string	$type	header|footer
	 * @return string
	 */
	public function getHeaderAndFooter( string $type = 'header' ): string
	{
		$key = "custom_{$type}_{$this->_id}";
		$content = "";
		switch( $type )
		{
			case 'header':
				$content = $this->custom_header;
				break;

			case 'footer':
				$content = $this->custom_footer;
				break;
		}

		if( $content )
		{
			if ( ! isset( Store::i()->$key ) )
			{
				Store::i()->$key = static::compileTemplate( $content, $key );
			}

			static::runProcessFunction( Store::i()->$key, $key );
			$themeFunction = 'IPS\\Theme\\'. $key;
			return $themeFunction();
		}

		return "";
	}

	/**
	 * Get all CSS. Raw means {resource..} tags and uncompiled
	 *
	 * @param array|string|null $app CSS app (e.g. core, forum)
	 * @param array|string|null $location CSS location (e.g. admin,global,front)
	 * @param array|string|null $path CSS group (e.g. custom, framework)
	 * @param int|null $returnType Determines the content returned
	 * @param bool $returnThisSetOnly
	 * @return array
	 */
	public function getAllCss( array|string|null $app= null, array|string|null $location=array(), array|string|null $path=array(), int $returnType=null, bool $returnThisSetOnly=false ): array
	{
		$returnType = ( $returnType === null )   ? static::RETURN_ALL   : $returnType;
		$app        = ( is_string( $app )      AND $app != ''      ) ? array( $app )      : $app;
		$location   = ( is_string( $location ) AND $location != '' ) ? array( $location ) : $location;
		$path       = ( is_string( $path )     AND $path != ''    )  ? array( $path )     : $path;
		$where      = array();
		$css	    = array();

		$where[] = "css_set_id IN (" . $this->_id .  ", 0)";

		if ( is_array( $app ) AND count( $app ) )
		{
			$where[] = "css_app IN ('" . implode( "','", $app ) . "')";
		}
	
		if ( is_array( $location ) AND count( $location ) )
		{
			$where[] = "css_location IN ('" . implode( "','", $location ) . "')";
		}
	
		if ( is_array( $path ) AND count( $path ) )
		{
			$where[] = "css_path IN ('" . implode( "','", $path ) . "')";
		}
		
		$select = ( $returnType & static::RETURN_BIT_NAMES ) ? 'css_set_id, css_app, css_location, css_path, css_id, css_name, css_modules, css_attributes, css_hidden' : '*';

		foreach(
			Db::i()->select(
				$select . ', INSTR(\',' . $this->_id . ', 0,\', CONCAT(\',\',css_set_id,\',\') ) as theorder',
				'core_theme_css',
				implode( " AND ", $where ),
				'css_location, css_path, css_name, theorder desc'
			)
			as $row )
		{
			/* App installed? */
			if ( ! Application::appIsEnabled( $row['css_app'] ) )
			{
				continue;
			}

			/* CSS not to be included */
			if ( ! empty( $row['css_hidden'] ) )
			{
				continue;
			}

			/* This set only? */
			if ( $returnThisSetOnly === true )
			{
				if ( $row['css_set_id'] != $this->_id )
				{
					continue;
				}
			}
			
			/* ensure set ID is correct */
			$row['css_set_id']  = $this->_id;
			$row['CssKey']      = str_replace( '.css', '', $row['css_app'] . '_' . $row['css_location'] . '_' . $row['css_path'] . '_' . $row['css_name'] );
			$row['jsDataKey']   = str_replace( '.', '--', $row['CssKey'] );
			
			if ( $returnType & static::RETURN_ALL_NO_CONTENT )
			{
				unset( $row['css_content'] );
				$css[ $row['css_app'] ][ $row['css_location'] ][ $row['css_path'] ][ $row['css_name'] ] = $row;
			}
			else if ( $returnType & static::RETURN_ALL )
			{
				$css[ $row['css_app'] ][ $row['css_location'] ][ $row['css_path'] ][ $row['css_name'] ] = $row;
			}
			else if ( $returnType & static::RETURN_BIT_NAMES )
			{
				$css[ $row['css_app'] ][ $row['css_location'] ][ $row['css_path'] ][] = $row['css_name'];
			}
			else if ( $returnType & static::RETURN_ARRAY_BIT_NAMES )
			{
				$css[] = $row['css_name'];
			}
		}
	
		if ( $returnType & static::RETURN_ARRAY_BIT_NAMES )
		{
			sort( $css );
			return $css;
		}
		
		ksort( $css );
	
		/* Pretty sure Mark can turn this into a closure */
		foreach( $css as $k => $v )
		{
			ksort( $css[ $k ] );
				
			foreach( $css[ $k ] as $ak => $av )
			{
				ksort( $css[ $k ][ $ak ] );
					
				if ( $returnType & static::RETURN_ALL )
				{
					foreach( $css[ $k ][ $ak ] as $bk => $bv )
					{
						ksort( $css[ $k ][ $ak ][ $bk ] );
					}
				}
			}
		}

		return $css;
	}

	/**
	 * @var array The CSS variables for this theme, cached to prevent multiple DB calls
	 */
	protected array $cssVariablesCached = [];

	/**
	 * Return the CSS variables for this theme
	 *
	 * @param int $flag
	 * @return array|null
	 */
	public function getCssVariables( int $flag=0 ): array|null
	{
		if ( ! isset( $this->cssVariablesCached[ $flag ] ) )
		{
			$customValues = [];

			/* Add custom settings that were added from other apps/themes */
			foreach( new ActiveRecordIterator(
						 Db::i()->select( '*', 'core_theme_editor_settings', [
							 [ "(setting_app not in ('" . implode( "','", IPS::$ipsApps ) . "') OR (setting_set_id != ? and setting_set_id=?) )", 0, $this->_id ]
						 ] ),
						 Setting::class
					 ) as $setting )
			{
				$value = $setting->value( true );
				if( is_array( $value ) )
				{
					foreach( $value as $_k => $_v )
					{
						$customValues[ $_k . '__' . $setting->key ] = $_v;
					}
				}
				else
				{
					$customValues[ $setting->key ] = $value;
				}
			}

			if( !( $flag & static::FORCE_DEFAULT ) )
			{
				if ( ! empty( $this->css_variables ) and $variables = json_decode( $this->css_variables, true ) )
				{
					foreach( $variables as $k => $v )
					{
						$customValues[ $k ] = $v;
					}
				}

				if( ! empty( $this->view_options ) and $layouts = json_decode( $this->view_options, true ) )
				{
					foreach( $layouts as $k =>  $v )
					{
						$customValues[ 'layout_' . $k ] = $v;
					}
				}
			}

			/* Add any pending changes */
			if( !( $flag & static::FORCE_DEFAULT ) )
			{
				$data = $this->editingSessionData()['data'] ?? [];
				foreach( $data as $group => $vars )
				{
					if( is_array( $vars ) )
					{
						$customValues = array_merge( $customValues, $vars );
					}
				}
			}

			if ( $flag & static::CUSTOM_ONLY )
			{
				/* We just want the custom/changed variables only */
				$this->cssVariablesCached[ $flag ] = $customValues;
				return $this->cssVariablesCached[ $flag ];
			}

			/* If we are still here, we want to merge in any custom variables with the default variables (from the master set) */
			if ( IN_DEV )
			{
				/* Try the dev theme */
				$cssData = file_get_contents( ROOT_PATH . "/applications/core/dev/css/global/framework/" . static::CSS_VARIABLE_FILENAME );
			}
			else
			{
				try
				{
					/* Try the master theme in the core/global/framework path if not IN_DEV */
					$cssData = Db::i()->select( 'css_content', 'core_theme_css', ['css_set_id=? AND css_app=? AND css_location=? AND css_path=? AND css_name=?', 0, 'core', 'global', 'framework', static::CSS_VARIABLE_FILENAME] )->first();
				}
				catch( UnderflowException )
				{
					$cssData = '';
				}
			}

			// Regular expression to find CSS variables
			$pattern = '/--([\w-]+)\s*:\s*([^;]+)/';
			preg_match_all( $pattern, $cssData, $matches );
			$variableNames = $matches[1] ?? [];
			$variableValues = $matches[2] ?? [];

			/* Include any editor settings that are not in the main CSS file */
			foreach( new ActiveRecordIterator(
						 Db::i()->select( '*', 'core_theme_editor_settings' ),
						 Setting::class
					 ) as $setting )
			{
				try
				{
					$value = $setting->value( ( $flag & static::FORCE_DEFAULT ) );
					if( is_array( $value ) )
					{
						foreach( $value as $_k => $_v )
						{
							$variableNames[] = $_k . '__' . $setting->key;
							$variableValues[] = $_v;
						}
					}
					else
					{
						$variableNames[] = $setting->key;
						$variableValues[] = $value;
					}
				}
				catch( InvalidArgumentException $e )
				{
					Log::debug( $e, 'template_error' );
					$variableValues[] = "";
				}
			}

			$this->cssVariablesCached[ $flag ] = array_merge( array_combine( $variableNames, $variableValues ), $customValues );
		}

		return $this->cssVariablesCached[ $flag ];
	}

	/**
	 * Return any saved CSS variables for <style id="themeVariables">
	 *
	 * @return string
	 */
	public function getInlineCssVariables(): string
	{
		$cssData = '';
		$customCssVars = $this->getCssVariables( Theme::CUSTOM_ONLY );

		if ( $customCssVars and count( $customCssVars ) )
		{
			$cssData = ":root{\n\n";

			foreach( $customCssVars as $key => $value )
			{
				/* Skip layout values, they don't belong here */
				if( str_starts_with( $key, 'layout' ) )
				{
					continue;
				}

				/* Parse the value based on the setting type */
				try
				{
					$setting = Setting::load( $key, 'setting_key' );
					$value = $setting->parsedValue( $value );
					if( $setting->type == Setting::SETTING_IMAGE )
					{
						if( empty( $value ) )
						{
							continue;
						}

						$value = "url('" . $value . "')";
					}
				}
				catch( OutOfRangeException )
				{
					$setting = null;
				}

				$variableName = str_replace( [ 'light__i-', 'dark__i-' ], '', $key );
				if( $value !== '' and ( $setting !== null or in_array( $variableName, static::$allowedNonSettingVariables ) ) )
				{
					$cssData .= "--{$key}: {$value};\n";
				}
			}

			$cssData .= "\n}";
		}

		return $cssData;
	}

	/**
	 * @param array $cssVariables
	 * @return void
	 */
	public function setCssVariables( array $cssVariables ): void
	{
		$this->css_variables = json_encode( $cssVariables );
		$this->save();

		$this->cssVariablesCached = [];
	}

	/**
	 * Get a CSS variable from its key
	 *
	 * @param $key
	 * @return string
	 */
	public function getCssVariableFromKey( $key ): string
	{
		$cssVariables = $this->getCssVariables();
		return $cssVariables[ $key ] ?? '';
	}

	/**
	 * Get the parsed css variable (what is directly added to themes). Returns an empty string when the provided key is unset
	 *
	 * @param string $key
	 *
	 * @return string
	 */
	public function getParsedCssVariableFromKey( string $key ) : string
	{
		$variables = $this->getCssVariables();
		$value = $variables[$key] ?? null;

		try
		{
			$setting = Setting::load( $key, "setting_key" );
			$value = $setting->parsedValue( $value ?? ( $setting->default ) );
		}
		catch ( OutOfRangeException ) {}

		return (string) $value;
	}

	/**
	 * Get the current scheme (light/dark)
	 *
	 * @return "light"|"dark"|string
	 */
	public function getCurrentCSSScheme() : string
	{
		if ( isset( Request::i()->cookie['scheme_preference']) and $this->getParsedCssVariableFromKey( "set__i-change-scheme" ) == "1" )
		{
			return Request::i()->cookie['scheme_preference'];
		}
		return $this->getParsedCssVariableFromKey( 'set__i-default-scheme' );
	}

	/**
	 * Get all templates. Raw means HTML logic and variables are still in {{format}}
	 *
	 * @param array|string $app				Template app (e.g. core, forum)
	 * @param array|string $location			Template location (e.g. admin,global,front)
	 * @param array|string $group				Template group (e.g. login, share)
	 * @param int|null $returnType			Determines the content returned
	 * @return array
	 */
	public function getAllTemplates( array|string $app=array(), array|string $location=array(), array|string $group=array(), int $returnType=null): array
	{
		$returnType = ( $returnType === null )  ? static::RETURN_ALL   : $returnType;
		$app        = ( is_string( $app )      AND $app != ''      ) ? array( $app )      : $app;
		$location   = ( is_string( $location ) AND $location != '' ) ? array( $location ) : $location;
		$group      = ( is_string( $group )    AND $group != ''    ) ? array( $group )    : $group;
		$where      = array();
		$templates  = array();

		if ( is_array( $app ) AND count( $app ) )
		{
			$where[] = "template_app IN ('" . implode( "','", $app ) . "')";
		}
		
		if ( is_array( $location ) AND count( $location ) )
		{
			$where[] = "template_location IN ('" . implode( "','", $location ) . "')";
		}
		
		if ( is_array( $group ) AND count( $group ) )
		{
			$where[] = "template_group IN ('" . implode( "','", $group ) . "')";
		}
	
		$select = ( $returnType & static::RETURN_BIT_NAMES ) ? 'template_app, template_location, template_group, template_id, template_name, template_data' : '*';
		
		foreach( Db::i()->select( $select, 'core_theme_templates', implode( " AND ", $where ), 'template_location, template_group, template_name' ) as $row )
		{
			/* App installed? */
			if ( ! Application::appIsEnabled( $row['template_app'] ) )
			{
				continue;
			}

			/* ensure set ID is correct */
			$row['TemplateKey']     = $row['template_app'] . '_' . $row['template_location'] . '_' . $row['template_group'] . '_' . $row['template_name'];
			$row['jsDataKey']       = str_replace( '.', '--', $row['TemplateKey'] );
			
			if ( $returnType & static::RETURN_ALL_NO_CONTENT )
			{
				unset( $row['template_content'] );
				$templates[ $row['template_app'] ][ $row['template_location'] ][ $row['template_group'] ][ $row['template_name'] ] = $row;
			}
			else if ( $returnType & static::RETURN_ALL )
			{
				$templates[ $row['template_app'] ][ $row['template_location'] ][ $row['template_group'] ][ $row['template_name'] ] = $row;
			}
			else if ( $returnType & static::RETURN_BIT_NAMES )
			{
				$templates[ $row['template_app'] ][ $row['template_location'] ][ $row['template_group'] ][] = $row['template_name'];
			}
			else if ( $returnType & static::RETURN_ARRAY_BIT_NAMES )
			{
				$templates[] = $row['template_name'];
			}
		}
	
		if ( $returnType & static::RETURN_ARRAY_BIT_NAMES )
		{
			sort( $templates );
			return $templates;
		}
		
		ksort( $templates );

		/* Pretty sure Mark can turn this into a closure */
		foreach( $templates as $k => $v )
		{
			ksort( $templates[ $k ] );
			
			foreach( $templates[ $k ] as $ak => $av )
			{
				ksort( $templates[ $k ][ $ak ] );
			
				if ( $returnType & static::RETURN_ALL )
				{
					foreach( $templates[ $k ][ $ak ] as $bk => $bv )
					{
						ksort( $templates[ $k ][ $ak ][ $bk ] );
					}
				}
			}
		}

		return $templates;
	}

	/**
	 * Get all available hook points in the templates
	 *
	 * @param array|string|null $app
	 * @return array
	 */
	public function getHookPoints( array|string $app=null ): array
	{
		$hookPoints = [];
		$where      = [ [ 'template_has_hookpoints=?', 1 ] ];

		if ( $app )
		{
			if ( is_array( $app ) )
			{
				$where[] = [ Db::i()->in( 'template_app', $app ) ];
			}
			else
			{
				$where[] = [ 'template_app=?', $app ];
			}
		}

		foreach( Db::i()->select( '*', 'core_theme_templates', $where ) as $row )
		{
			$path = $row['template_app'] . '/' . $row['template_location'] . '/' . $row['template_group'] . '/' . $row['template_name'];
			$hookPoints[ $path ] = static::extractHookNames( $row['template_content'] );
		}

		return $hookPoints;
	}

	/**
	 * Save a theme set
	 *
	 * @param array $data	Skin set data
	 * @return	void
	 */
	public function saveSet( array $data=array() ): void
	{
		$save    = array();
		$fields  = array( 'name', 'key', 'permissions', 'is_default', 'author_name', 'author_url', 'resource_dir', 'emo_dir', 'hide_from_list', 'order', 'version', 'long_version', 'update_check' );

		foreach( $fields as $k )
		{
			if ( isset( $data[ 'set_' . $k ] ) )
			{
				$save[ 'set_' . $k ] = $data[ 'set_' . $k ];
			}
		}
		
		if ( ! $this->_id )
		{
			$save['set_long_version'] = ( ! empty( $save['set_long_version'] ) ) ? (int) $save['set_long_version'] : Application::load( 'core' )->long_version;
		}
		else if ( isset( $save['set_long_version'] ) )
		{
			$save['set_long_version'] = intval( $save['set_long_version'] );
		}

		foreach( [ 'front', 'front-dark', 'mobile', 'mobile-dark' ] as $type )
		{
			if ( isset( $data['logo'][ $type ] ) )
			{
				$this->_data['logo'][ $type ] = $data['logo'][ $type ];
			}
		}

		if ( isset( $data['set_name'] ) )
		{
			Lang::saveCustom( 'core', "core_theme_set_title_{$this->_id}", $data['set_name'] );
		}
		
		$save['set_logo_data'] = ( isset( $this->_data['logo'] ) ? json_encode( $this->_data['logo'] ) : '{}' );
		
		if ( isset( $data['set_css_map'] ) )
		{
			$save['set_css_map'] = json_encode( $data['set_css_map'] );
		}
		
		if ( isset( $data['set_resource_map'] ) )
		{
			$save['set_resource_map'] = json_encode( $data['set_resource_map'] );
		}

		if ( isset( $data['set_theme_editor_data'] ) )
		{
			$save['set_theme_editor_data'] = json_encode( $data['set_theme_editor_data'] );
		}

		Db::i()->update( 'core_themes', $save, array( 'set_id=?', (int) $this->_id ) );

		unset( Store::i()->themes );
	}
	
	/**
	 * Copies all current theme templates and CSS to the history table for use with diff and/or conflict checking
	 * when importing new templates.
	 *
	 * @return void
	 */
	public function saveHistorySnapshot(): void
	{
		/* Remove all current template records for this theme set */
		Db::i()->delete( 'core_theme_content_history', array( 'content_set_id=?', $this->id ) );
	
		/* Templates */
		Db::i()->insert( 'core_theme_content_history', Db::i()->select( "null, template_set_id, 'template', template_app, template_location, template_group, template_name, template_data, template_content, IFNULL(template_version, 10000), template_updated", 'core_theme_templates', array( 'template_set_id=?', $this->id ) ) );
	
		/* CSS */
		Db::i()->insert( 'core_theme_content_history', Db::i()->select( "null, css_set_id, 'css', css_app, css_location, css_path, css_name, css_attributes, css_content, IFNULL(css_version, 10000), css_updated", 'core_theme_css', array( 'css_set_id=?', $this->id ) ) );
	}
	
	/**
	 * Get diffs. Returns an array of CSS and template diffs between latest version and previous version.
	 *
	 * @return	array	array( 'templates' => array(), 'css' => array() )
	 */
	public function getDiff(): array
	{
		$templates = array();
		$css       = array();
		$results   = [];
		$history   = array( 'templates' => [], 'css' => [] );
		
		require_once ROOT_PATH . "/system/3rd_party/Diff/class.Diff.php";
		
		foreach(Db::i()->select(
				"*, MD5( CONCAT( content_app, '.', content_location, '.', content_path, '.', content_name ) ) as bit_key",
				'core_theme_content_history',
				array( 'content_set_id=?', $this->id )
		)->setKeyField('bit_key') as $key => $data )
		{
			if ( $data['content_type'] == 'template' )
			{
				$history['template'][ $key ] = $data;
			}
			else
			{
				$history['css'][ $key ] = $data;
			}
		}

		$results['templates'] = iterator_to_array( Db::i()->select(
				'*, MD5( CONCAT( template_app, \'.\', template_location, \'.\', template_group, \'.\', template_name ) ) as bit_key',
				'core_theme_templates',
				array( 'template_set_id=?', $this->id )
		)->setKeyField('bit_key') );
		
		$results['css'] = iterator_to_array( Db::i()->select(
				'*, MD5( CONCAT( css_app, \'.\', css_location, \'.\', css_path, \'.\', css_name ) ) as bit_key',
				'core_theme_css',
				array( 'css_set_id=?', $this->id )
		)->setKeyField('bit_key') );

		$masterTemplateBits  = Theme::master()->getAllTemplates();

		/* Find changed and new template bits */
		foreach( $results['templates'] as $key => $data )
		{
			$data['added']   = false;
			$data['deleted'] = false;

			$masterSetTemplate = $masterTemplateBits[$data['template_app']][$data['template_location']][$data['template_group']][$data['template_name']] ?? NULL;

			if ( isset( $history['template'][ $key ] ) )
			{
				$data['oldHumanVersion'] = Application::load( $history['template'][ $key ]['content_app'] )->getHumanVersion( $history['template'][ $key ]['content_long_version'] );
				$data['newHumanVersion'] = Application::load( $results['templates'][ $key ]['template_app'] )->getHumanVersion( $results['templates'][ $key ]['template_version'] );
				
				if ( md5( $history['template'][ $key ]['content_content'] ) != md5( $data['template_content'] ) )
				{
					$data['diff'] = Diff::toTable( Diff::compare( $history['template'][ $key ]['content_content'], $data['template_content'] ) );
				}
				else
				{
					unset( $results['templates'][ $key ] );
					unset( $history['template'][ $key ] );
					continue;
				}
			}
			else if ( $masterSetTemplate and md5( $masterSetTemplate['template_content'] ) != md5( $data['template_content'] ) )
			{
				$data['diff'] = Diff::toTable( Diff::compare( $masterSetTemplate['template_content'], $data['template_content'] ) );
			}
			else
			{
				$data['added'] = true;
				$data['diff']  = Diff::toTable( Diff::compare( '', $data['template_content'] ) );
			}
			
			$templates[ $data['template_app'] ][ $data['template_location'] ][ $data['template_group'] ][ $data['template_name'] ] = $data;
		}
		
		/* Find changed and new CSS bits */
		foreach( $results['css'] as $key => $data )
		{
			$data['added']   = false;
			$data['deleted'] = false;
			
			if ( isset( $history['css'][ $key ] ) )
			{
				$data['oldHumanVersion'] = Application::load( $history['css'][ $key ]['content_app'] )->getHumanVersion( $history['css'][ $key ]['content_long_version'] );
				$data['newHumanVersion'] = Application::load( $results['css'][ $key ]['css_app'] )->getHumanVersion( $results['css'][ $key ]['css_version'] );
				
				if ( md5( $history['css'][ $key ]['content_content'] ) != md5( $data['css_content'] ) )
				{
					$data['diff'] = Diff::toTable( Diff::compare( $history['css'][ $key ]['content_content'], $data['css_content'] ) );
				}
				else
				{
					unset( $results['css'][ $key ] );
					unset( $history['css'][ $key ] );
					continue;
				}
			}
			else
			{
				$data['added'] = true;
				$data['diff']  = Diff::toTable( Diff::compare( '', $data['css_content'] ) );
			}
			
			$css[ $data['css_app'] ][ $data['css_location'] ][ $data['css_path'] ][ $data['css_name'] ] = $data;
		}
	
		/* Find deleted template bits */
		foreach( array_diff( array_keys( $history['template'] ), array_keys( $results['templates'] ) ) as $key )
		{
			$data = $history['template'][ $key ];
			
			$templates[ $data['content_app'] ][ $data['content_location'] ][ $data['content_path'] ][ $data['content_name'] ] = array(
				'template_app' 		=> $data['content_app'],
				'template_location' => $data['content_location'],
				'template_group'    => $data['content_path'],
				'template_name'     => $data['content_name'],
				'template_content'  => $data['content_content'],
				'diff'    			=> Diff::toTable( Diff::compare( $history['template'][ $key ]['content_content'], '' ) ),
				'added'				=> false,
				'deleted'			=> true
			);
		}
		
		/* Find deleted CSS bits */
		foreach( array_diff( array_keys( $history['css'] ), array_keys( $results['css'] ) ) as $key )
		{
			$data = $history['css'][ $key ];
			
			$css[ $data['content_app'] ][ $data['content_location'] ][ $data['content_path'] ][ $data['content_name'] ] = array(
				'css_app' 		=> $data['content_app'],
				'css_location' 	=> $data['content_location'],
				'css_path'    	=> $data['content_path'],
				'css_name'     	=> $data['content_name'],
				'css_content'  	=> $data['content_content'],
				'diff'    		=> Diff::toTable( Diff::compare( $history['css'][ $key ]['content_content'], '' ) ),
				'added'			=> false,
				'deleted'		=> true
			);
		}
			
		/* Now sort */
		foreach( $templates as $k => $v )
		{
			ksort( $templates[ $k ] );
				
			foreach( $templates[ $k ] as $ak => $av )
			{
				ksort( $templates[ $k ][ $ak ] );
					
				foreach( $templates[ $k ][ $ak ] as $bk => $bv )
				{
					ksort( $templates[ $k ][ $ak ][ $bk ] );
				}
			}
		}
		
		foreach( $css as $k => $v )
		{
			ksort( $css[ $k ] );
		
			foreach( $css[ $k ] as $ak => $av )
			{
				ksort( $css[ $k ][ $ak ] );
					
				foreach( $css[ $k ][ $ak ] as $bk => $bv )
				{
					ksort( $css[ $k ][ $ak ][ $bk ] );
				}
			}
		}
		
		return array( 'templates' => $templates, 'css' => $css );
	}

	/**
	 * Delete compiled templates
	 * Removes compiled templates bits for all themes that match the arguments
	 *
	 * @param string|null $app		Application Directory (core, forums, etc)
	 * @param array|string|null $location	Template location (front, admin, global, etc)
	 * @param array|string|null $group		Template group (forms, messaging, etc)
	 * @param int|null $themeId	Limit to a specific theme (and children)
	 * @return 	void
	 */
	public static function deleteCompiledTemplate( string $app=null, array|string|null $location=null, array|string|null $group=null, int $themeId=null ): void
	{
		$where     = array();
		$themeSets = array( 0 );

		if ( $app !== NULL )
		{
			$where[] = array( 'template_app=?', $app );
		}

		if ( $location !== null )
		{
			$where[] = array( Db::i()->in( 'template_location', ( is_array( $location ) ) ? $location : array( $location ) ) );
		}
		
		if ( $group !== null )
		{
			$where[] = array( Db::i()->in( 'template_group', ( is_array( $group ) ) ? $group : array( $group ) ) );
		}

		if ( ! empty( $themeId ) )
		{
			$themeSet  = static::load( $themeId );
			$where[] = array( Db::i()->in( 'template_set_id', array_keys( $themeSets ) ) );
		}
				
		foreach(
			Db::i()->select(
				"template_app, template_location, template_group, MD5( CONCAT(',', template_app, ',', template_location, ',', template_group) ) as group_key",
				'core_theme_templates',
				$where,
				NULL, NULL, array( 'group_key', 'template_group', 'template_app', 'template_location' )
			)
			as $groupKey => $data
		){
			/* ... remove from each theme */
			foreach( static::themes() as $id => $set )
			{
				if ( $themeId === null OR in_array( $id, array_keys( $themeSets ) ) )
				{
					$key = strtolower( 'template_' . $set->id . '_' .static::makeBuiltTemplateLookupHash( $data['template_app'], $data['template_location'], $data['template_group'] ) . '_' . static::cleanGroupName( $data['template_group'] ) );
			
					unset( Store::i()->$key );
				}
			}
		}
	}
	
	/**
	 * Delete compiled Css
	 * Removes compiled Css for all themes that match the arguments
	 *
	 * @param array|string|null $app		CSS Directory (core, forums, etc)
	 * @param array|string|null $location	CSS location (front, admin, global, etc)
	 * @param string|null $path		CSS path (forms, messaging, etc)
	 * @param string|null $name		CSS file to remove
	 * @param int|null $themeId	Limit to a specific theme (and children)
	 * @return 	void
	 */
	public static function deleteCompiledCss( array|string|null $app=null, array|string|null $location=null, string|null $path=null, string $name=null, int $themeId=null ): void
	{
		$where     = array();
		$themeSets = array( 0 );

		if ( $themeId !== null )
		{
			$themeSet  = static::load( $themeId );
			$themeSets = array( $themeId => $themeSet );
			$where[] = array( Db::i()->in( 'css_set_id', array_keys( $themeSets ) ) );
		}

		if ( $app === null )
		{
			/* Each theme... */
			foreach( static::themes() as $id => $set )
			{
				if ( $themeId === null OR in_array( $id, array_keys( $themeSets ) ) )
				{
					File::getClass( 'core_Theme')->deleteContainer('css_built_' . $set->_id );

					$set->css_map = array();
					$set->css_updated = time();

					/* Delete any custom CSS file */
					if( $set->core_css_filename )
					{
						File::get( 'core_Theme', $set->core_css_filename )->delete();
						$set->core_css_filename = null;
					}

					$set->save();
				}
			}

			/* ACP CSS */
			Settings::i()->changeValues( array( 'acp_css_map' => '[]' ) );

			/* Done */
			return;
		}

		/* Custom CSS? */
		if ( $app == 'core' and $location == 'front' and $path == 'custom' and $name == 'custom.css' and $themeId )
		{
			/* @var $themeSet Theme */
			$map = $themeSet->css_map;
			$key = static::makeBuiltTemplateLookupHash( 'core', 'front', 'custom/custom.css' );
			if ( isset( $map[ $key ] ) )
			{
				File::get( 'core_Theme', $map[ $key ] )->delete();
				unset( $map[ $key ] );

				$themeSet->css_map = $map;
			}

			return;
		}

		/* Deconstruct build grouping */
		if ( $name !== null )
		{
			if ( isset( static::$buildGrouping['css'][ $app ][ $location ] ) )
			{
				foreach( static::$buildGrouping['css'][ $app ][ $location ] as $grouped )
				{
					if ( str_replace( '.css', '', $name ) == $grouped )
					{
						$path = $grouped;
						$name = null;
					}
				}
			}
		}

		$where[] = array( Db::i()->in( 'css_app', ( is_array( $app ) ) ? $app : array( $app ) ) );

		if ( $location !== null )
		{
			$where[] = array( Db::i()->in( 'css_location', ( is_array( $location ) ) ? $location : array( $location ) ) );
		}
	
		if ( $path !== null )
		{
			$where[] = array( 'css_path=?', $path );
		}

		$css = iterator_to_array( Db::i()->select( "*", 'core_theme_css', $where )->setKeyField('css_id') );
		if ( count( $css ) )
		{
			/* Each theme... */
			static::$gotAll = false;
			foreach( static::themes() as $id => $set )
			{
				if ( $themeId === null OR in_array( $id, array_keys( $themeSets ) ) )
				{
					$built = array();
					$map   = $set->css_map;

					foreach( $css as $cssId => $data )
					{
						if ( isset( static::$buildGrouping['css'][ $data['css_app'] ][ $data['css_location'] ] ) AND in_array( $data['css_path'], static::$buildGrouping['css'][ $data['css_app'] ][ $data['css_location'] ] ) )
						{
							$key = static::makeBuiltTemplateLookupHash( $data['css_app'], $data['css_location'], $data['css_path'] );
					
							if ( ! isset( $built[ $key ] ) )
							{
								$data['css_name']    = $data['css_path'] . '.css';
								$data['css_path']    = '.';
								$data['css_content'] = '';
								
								$built[ $key ] = $data;
							}
						}
						else
						{
							/* ... remove the CSS Files */
							$key = static::makeBuiltTemplateLookupHash( $data['css_app'], $data['css_location'], $data['css_path'] . '/' . $data['css_name'] );
							
							if ( isset( $map[ $key ] ) )
							{
								File::get( 'core_Theme', $map[ $key ] )->delete();
								unset( $map[ $key ] );
							}
						}
					}
				
					/* Write combined css */
					if ( count( $built ) )
					{ 
						foreach( $built as $cssData )
						{
							$key = static::makeBuiltTemplateLookupHash( $cssData['css_app'], $cssData['css_location'], $cssData['css_path'] . '/' . $cssData['css_name'] );
					
							if ( isset( $map[ $key ] ) )
							{
								File::get( 'core_Theme', $map[ $key ] )->delete();
								unset( $map[ $key ] );
							}
						}
					}

					/* Update mappings */
					$theme = static::load( $id );
					$theme->css_map = $map;
					$theme->css_updated = time();
					$theme->save();
				}
			}
		}
	}
	
	/**
	 * Delete compiled resources
	 * Removes stored resource file objects and associated mappings but doesn't actually remove the resource
	 * row from the database.
	 *
	 * @param array|string|null $app		App Directory (core, forums, etc)
	 * @param array|string|null $location	location (front, admin, global, etc)
	 * @param array|string|null $path		Path (forms, messaging, etc)
	 * @param string|null $name		Resource file to remove
	 * @param int|null $themeId	Limit to a specific theme (and children)
	 * @return 	void
	 */
	public static function deleteCompiledResources( array|string|null $app=null, array|string|null $location=null, array|string|null $path=null, string $name=null, int $themeId=null ): void
	{
		$query     = array();
		$themeSet  = null;
		$map       = array();
		
		if ( ! empty( $themeId ) )
		{
			$themeSet = static::load( $themeId );
		}

		if ( $app === null )
		{
			/* Each theme... */
			foreach( static::themes() as $id => $set )
			{
				if ( $themeId === null or $themeId == $set->_id )
				{
					File::getClass( 'core_Theme' )->deleteContainer('set_resources_' . $set->_id );

					$set->resource_map = array();

                    /* Clear any cached logos */
                    $logoData = $set->logo_data ? json_decode( $set->logo_data, true ) : [];
                    foreach( $logoData as $type => $logo )
                    {
                        if( isset( $logo['url'] ) )
                        {
                            unset( $logoData[ $type ]['url'] );
                        }
                    }
                    $set->logo_data = json_encode( $logoData );

					$set->save();
				}
			}
		}

		if ( $app !== NULL )
		{
			$query[] = Db::i()->in( 'resource_app', ( is_array( $app ) ) ? $app : array( $app ) );
		}

		if ( $location !== null )
		{
			$query[] = Db::i()->in( 'resource_location', ( is_array( $location ) ) ? $location : array( $location ) );
		}
		
		if ( $path !== null )
		{
			$query[] = Db::i()->in( 'resource_path', ( is_array( $path ) ) ? $path : array( $path ) );
		}
		
		if ( $themeSet !== null )
		{
			$query[] = Db::i()->in( 'resource_set_id', [ $themeSet ] );
		}

		if ( $app !== NULL )
		{
			foreach (Db::i()->select( "*", 'core_theme_resources', array( implode( ' AND ', $query ) ) ) as $row )
			{
				try
				{
					if ( !isset( $set ) OR !isset( $map[ $set->id ] ) )
					{
						$set = static::load( $row['resource_set_id'] );

						$map[ $set->id ] = $set->resource_map;
					}

					$name = static::makeBuiltTemplateLookupHash( $row['resource_app'], $row['resource_location'], $row['resource_path'] ) . '_' . $row['resource_name'];

					if ( isset( $map[ $set->id ][ $name ] ) )
					{
						unset( $map[ $set->id ][ $name ] );
						
						try
						{
							if ( $row['resource_filename'] )
							{
								File::get( 'core_Theme', $row['resource_filename'] )->delete();
							}
						}
						catch ( InvalidArgumentException $ex ) { }
					}
				}
				catch ( OutOfRangeException $ex )
				{
					$map[$row['resource_set_id']] = array();
				}
			}
		}

		Db::i()->update( 'core_theme_resources', array( 'resource_filename' => null ), ( count( $query ) ? array( implode( ' AND ', $query ) ) : NULL ) );
		
		/* Update mappings */
		foreach( $map as $setId => $data )
		{
			try
			{
				$set = static::load( $setId );
				$set->resource_map = $data;
				$set->save();
				$set->saveSet();
			}
			catch( OutOfRangeException $ex ) { }
		}
		
		if ( empty( $themeId ) )
		{
			/* ACP CSS and Resources */
			Settings::i()->changeValues( array( 'acp_css_map' => '[]' ) );
			unset( Store::i()->acp_resource_map );
		}
	}

	/**
	 * Run the template content via the compiler and eval methods to see if there's any broken syntax
	 *
	 * @param string $content        The template content
	 * @param string $params         The template params
	 * @return  false                   False if the template is good
	 * @throws  LogicException          If template has issues, $e->getMessage() has the details
	 */
	public static function checkTemplateSyntax( string $content, string $params='' ): bool
	{
		ob_start();

		try
		{
			static::makeProcessFunction( $content, 'unique_function_so_it_doesnt_look_in_function_exists_' . mt_rand(), $params );
		}
		catch( ParseError $e )
		{
			ob_end_clean();
			throw new LogicException( $e->getMessage() );
		}

		$return = ob_get_contents();
		ob_end_clean();

		if ( $return )
		{
			throw new LogicException( $return );
		}

		return false;
	}
	
	/**
	 * Make process function
	 * Parses template into executable function and evals it.
	 *
	 * @param string $content		Content with variables and parse tags
	 * @param string $functionName	Desired function name
	 * @param string $params			Parameter list
	 * @param bool $isHTML			If TRUE, HTML will automatically be escaped
	 * @param bool $isCSS			If TRUE, the plugins will be checked for $canBeUsedInCss
	 * @return void
	 */
	public static function makeProcessFunction( string $content, string $functionName, string $params='', bool $isHTML=TRUE, bool $isCSS=FALSE ) : void
	{
		static::runProcessFunction( static::compileTemplate( $content, $functionName, $params, $isHTML, $isCSS ), $functionName );
	}
	
	/**
	 * Make process function
	 * Parses template into executable function and evals it.
	 *
	 * @param string $content		Compiled content with variables and parse tags
	 * @param string $functionName	Desired function name
	 * @return	void
	 */
	public static function runProcessFunction( string $content, string $functionName ): void
	{
		/* If it's already been built, we don't need to do it again */
		if( function_exists( 'IPS\Theme\\' . $functionName ) )
		{
			return;
		}

		/* Build Function */
		$function = 'namespace IPS\Theme;' . "\n" . $content;
		
		/* Make it */
		if (DEBUG_TEMPLATES)
		{
			static::runDebugTemplate( $functionName, $function );
		}
		else
		{
			if( eval( $function ) === FALSE )
			{
				/* Throw exception for PHP 5 */
				throw new InvalidArgumentException;
			}
		}
	}
	
	/**
	 * Run the template as a PHP file, not an eval to debug errors
	 *
	 * @param string $functionName	Function name
	 * @param string $content		Compiled content with variables and parse tags
	 * @return	void
	 */
	public static function runDebugTemplate( string $functionName, string $content ): void
	{
		$temp = tempnam( TEMP_DIRECTORY, $functionName );
		file_put_contents( $temp, "<?php\n" . $content );
		include $temp;
		register_shutdown_function( function( $temp ) {
			unlink( $temp );
		}, $temp );
	}
	
	/**
	 * Expand shortcuts
	 *
	 * @param string $content		Content with shortcuts
	 * @return	string	Content with shortcuts expanded
	 */
	public static function expandShortcuts( string $content ): string
	{
		/* Parse shortcuts */
		foreach ( array( 'member' => 'loggedIn', 'settings' => 'i', 'output' => 'i' ) as $class => $function )
		{
			$content = preg_replace( '/(^|[^$\\\])' . $class . "\.(\S+?)/", '$1\IPS\\' . IPS::mb_ucfirst( $class ) . '::' . $function . '()->$2', $content );
		}

		$content = preg_replace( '/(^|[^$\\\])(?:request\\.|\\\\?IPS\\\\Request::i\\(\\)->)(\S+?)/', '$1\IPS\Widget\Request::i()->$2', $content );

		$content = preg_replace( '/(^|[^$\\\])cookie\.(.([a-zA-Z0-9_]+))/', '$1 (\IPS\\Request::i()->cookie[\'$2\'])', $content );
		
		/* Parse special CSS variable shortcut */
		$content = preg_replace( '/(^|[^$\\\])(cssvar|theme)\\.([a-zA-Z0-9_\-]+)/', '$1\IPS\Theme::i()->getCssVariableFromKey(\'$3\')', $content );

		return preg_replace( '/(^|[^$\\\])view\.([a-zA-Z0-9_\-]+)/', '$1\IPS\Theme::i()->getLayoutValue(\'$2\')', $content );
	}

	/**
	 * @param string $content
	 * @param string $path
	 * @param string $params
	 * @return string
	 */
	public static function expandHookPoints( string $content, string $path, string $params='' ): string
	{
		if ( ! mb_stristr( $content, 'data-ips-hook') )
		{
			return $content;
		}

		/* Swap out certain tags that confuse phpQuery */
		$content = preg_replace( '/<(\/)?(html|head|body)(>| (.+?))/', '<$1temp$2$3', $content );
		$content = str_replace( '<!DOCTYPE html>', '<tempdoctype></tempdoctype>', $content );

		$domQueryI = 0;
		$domQueryStore = array();
		$jsonAttrI = 0;
		$jsonAttrStore = array();

		/* Remove raw JS as this can cause a timeout if there is a lot of it */
		$content = preg_replace_callback( '#<script\b[^>]*>([\s\S]*?)<\/script>#', function( $matches ) use ( &$domQueryI, &$domQueryStore )
		{
			$domQueryStore[ ++$domQueryI ] = $matches[0];
			return 'he-' . $domQueryI . '--';
		}, $content );

		/* We sometimes need to use single quotes as the data attr contains json */
		$content = preg_replace_callback( "/([\d\w0-9-]+?)='\{([^']+?)\|raw\}'/", function( $matches ) use ( &$jsonAttrI, &$jsonAttrStore )
		{
			$jsonAttrStore[ ++$jsonAttrI ] = $matches;
			return $matches[1] . '="json--' . $jsonAttrI . '--"';
		}, $content );

		/* Remove any HTML logic as it confuses DOMDocument */
		$content = preg_replace_callback( array( '/{{?(?>[^{}]|(?R))*}?}/', '/\{([a-z]+?=([\'"]).+?\\2 ?+)}/' ), function( $matches ) use ( &$domQueryI, &$domQueryStore )
		{
			$domQueryStore[ ++$domQueryI ] = $matches[0];
			return 'he-' . $domQueryI . '--';
		}, $content );

		/* Parse the HTML */
		$doc = new DOMDocument( '1.0', 'UTF-8' );
		$doc->loadHTML( DOMDocument::wrapHtml( $content ) );
		$xpath = new DOMXPath($doc);

		if( $params )
		{
			preg_match_all( '/(\$[a-zA-Z0-9_]+)/', $params, $matches );
			$paramList = implode( ',', array_unique( $matches[1] ) );
		}
		else
		{
			$paramList = '';
		}

		/* Fetch all tags with the data-ips-hook attribute */
		$tags = $xpath->query("//*[@data-ips-hook]");
		foreach( $tags as $element )
		{
			if ( $element->hasAttribute('data-ips-hook') )
			{
				/* Add in content BEFORE the tag opens */
				if ( $element->parentNode )
				{
					$element->parentNode->insertBefore( new DOMText( '{customtemplate="' . $path . '" params="' . $paramList . '" hook="' . $element->getAttribute( 'data-ips-hook' ) . ':before"}' ), $element );
				}

				/* Add in content AFTER the tag opens */
				if ( $firstChild = $element->firstChild )
				{
					$element->insertBefore( new DOMText( '{customtemplate="' . $path . '" params="' . $paramList . '" hook="' . $element->getAttribute( 'data-ips-hook' ) . ':inside-start"}' ), $firstChild );
				}

				/* Add in content BEFORE the tag closes */
				$element->appendChild( new DOMText( '{customtemplate="' . $path . '" params="' . $paramList . '" hook="' . $element->getAttribute( 'data-ips-hook' ) . ':inside-end"}' ) );

				/* Add in content AFTER the tag has closed */
				if ( $element->parentNode and $nextSibling = $element->nextSibling )
				{
					$element->parentNode->insertBefore( new DOMText( '{customtemplate="' . $path . '" params="' . $paramList . '" hook="' . $element->getAttribute( 'data-ips-hook' ) . ':after"}' ), $nextSibling );
				}
				elseif( $element->parentNode and $element->nextSibling === null )
				{
					$element->parentNode->appendChild( new DOMText( '{customtemplate="' . $path . '" params="' . $paramList . '" hook="' . $element->getAttribute( 'data-ips-hook' ) . ':after"}' ) );
				}
			}
		}

		$return = substr( $doc->saveHTML( $doc->getElementsByTagName('body')->item(0) ), 6, -7);

		/* Put our single quoted data back */
		foreach( $jsonAttrStore as $id => $matches )
		{
			$return = preg_replace( '#' . $matches[1] . '="json--' . $id . '--"#', $matches[0], $return );
		}

		/* Put our moved code back */
		$return = preg_replace_callback( '/he-(.+?)--/', function( $matches ) use ( $domQueryStore )
		{
			return $domQueryStore[$matches[1]] ?? '';
		}, $return );

		/* Swap back certain tags that confuse */
		$return = preg_replace( '/<(\/)?temp(html|head|body)(.*?)>/', '<$1$2$3>', $return );
		$return = str_replace( '<tempdoctype></tempdoctype>', '<!DOCTYPE html>', $return );

		return $return;
	}

	/**
	 * Process template into executable code.
	 *
	 * @param string $content		Content with variables and parse tags
	 * @param string|null $functionName	Desired function name if given, otherwise <<<CONTENT CONTENT; is returned without a function wrapper or $return .= statement
	 * @param string $params			Parameter list
	 * @param bool $isHTML			If TRUE, HTML will automatically be escaped
	 * @param bool $isCSS			If TRUE, the plugins will be checked for $canBeUsedInCss
	 * @param string|null $app			Application the template belongs to
	 * @param string|null $location		Location the template belongs to (admin,front)
	 * @param string|null $group			Group the template belongs to
	 * @return	string	Function name to eval
	 * @throws	InvalidArgumentException
	 */
	public static function compileTemplate( string $content, ?string $functionName, string $params='', bool $isHTML=TRUE, bool $isCSS=FALSE, string $app=null, string $location=null, string $group=null ): string
	{
		$calledClass = get_called_class();

		if( $functionName == 'theme_core_front_global_footer' or ( $functionName == 'footer' and $app == 'core' and $location == 'front' and $group == 'global' ) )
		{
			$content = $content . "\n<p class='ipsCopyright'>
	<span class='ipsCopyright__user'>{lang=\"copyright_line_value\"}</span>
	{{if !\$licenseData = \IPS\IPS::licenseKey() or !isset(\$licenseData['products']['copyright']) or !\$licenseData['products']['copyright']}}<a rel='nofollow' title='Invision Community' href='https://www.invisioncommunity.com/'>Powered by <span translate='no'>Invision Community</span></a>{{endif}}
</p>";
		}

		/* Expand hook points */
		if ( ! $isCSS and mb_stristr( $content, 'data-ips-hook=' ) )
		{
			if ( $app == NULL and $location == NULL and $group == NULL and mb_substr( $functionName, 0, 6 ) == 'theme_' )
			{
				/* Yeah, preg_match because we can't just explode on '_' as some template names may have them */
				preg_match( '#theme_([^_]+?)_([^_]+?)_([^_]+?)_(.*)#', $functionName, $matches );
				$path = $matches[1] . '/' . $matches[2] . '/' . $matches[3] . '/' . $matches[4];
			}
			else
			{
				$path = $app . '/' . $location . '/' . $group . '/' . $functionName;
			}

			$content = static::expandHookPoints( $content, $path, $params );
		}

		return static::_compileTemplate( $content, $functionName, $isHTML, $isCSS, $params );
	}

	/**
	 * Compile the template into executable code
	 * @note This is a separate function so that we can call it outside of the main function
	 *
	 * @param string $content
	 * @param ?string $functionName
	 * @param bool $isHTML
	 * @param bool $isCSS
	 * @param string $params
	 * @return string
	 */
	public static function _compileTemplate( string $content, ?string $functionName, bool $isHTML=true, bool $isCSS=FALSE, string $params='' ): string
	{
		$contentName = $isCSS ? "'IPSCONTENT'" : 'IPSCONTENT';
		$calledClass = get_called_class();

		// Prevent anyone from attempting to hijack the theme system
		$content = preg_replace( "#((?:^|\\n)\\s*IPSCONTENT;)#si", "\$1\n\$return .= <<<IPSCONTENT_ESC\n\$1\nIPSCONTENT_ESC;\n\$return .= <<<{$contentName}\n", $content );

		/* Parse out {{code}} tags */
		$content = preg_replace_callback( '/{{(.+?)}}/', function( $matches ) use ( $contentName )
		{
			/* Parse shortcuts */
			$matches[1] = Theme::expandShortcuts( $matches[1] );
			/* Make conditionals and loops valid PHP */
			if( $matches[1] === 'else' )
			{
				$matches[1] .= ':';
			}
			elseif( substr( $matches[1], 0, 3 ) === 'end' )
			{
				$matches[1] .= ';';
			}
			elseif( in_array( substr( $matches[1], 0, 4 ), array( 'for ', 'for(' ) ) )
			{
				$matches[1] = 'for (' . substr( $matches[1], 3 ) . ' ):';
			}
			else
			{
				foreach ( array( 'if', 'elseif', 'foreach' ) as $tag )
				{
					if( substr( $matches[1], 0, strlen( $tag ) ) === $tag )
					{
						$matches[1] = $tag .' (' . substr( $matches[1], strlen( $tag ) ) . ' ):';
					}
				}
			}

			return "\nIPSCONTENT;\n\n{$matches[1]}\n\$return .= <<<{$contentName}\n";
		}, $content );

		/* Make sure any literal \{\{This should not be treated as PHP\}\} is converted back into {{this shoud not be treated as PHP}} */
		$content = preg_replace( '/\\\{\\\{(.+?)\\\}\\\}/', '{{\1}}', $content );

		if ( ! $functionName )
		{
			$function = <<<PHP
		<<<{$contentName}\n
{$content}
IPSCONTENT;\n
PHP;
		}
		else
		{
			$function = <<<PHP
	function {$functionName}( {$params} ) {
		\$return = '';
		\$return .= <<<{$contentName}\n
{$content}
IPSCONTENT;\n
		return \$return;
}
PHP;
		}

		/* Parse {plugin="foo"} tags */
		$function = preg_replace_callback
		(
			'/\{([a-z]+?=([\'"]).+?\\2 ?+)}/',
			function( $matches ) use ( $functionName, $isCSS, $calledClass, $contentName )
			{
				/* Work out the plugin and the values to pass */
				preg_match_all( '/(.+?)='.$matches[2].'([^' . $matches[2] . ']*)'.$matches[2].'\s?/', $matches[1], $submatches );

				$plugin = array_shift( $submatches[1] );
				$pluginClass = 'IPS\\Output\\Plugin\\' . IPS::mb_ucfirst( $plugin );

				$value = array_shift( $submatches[2] );
				$options = array();

				foreach ( $submatches[1] as $k => $v )
				{
					$options[ $v ] = $submatches[2][ $k ];
				}

				/* Work out if this plugin belongs to an application, and if so, include it */
				if( !class_exists( $pluginClass ) )
				{
					foreach( Application::applications() as $app )
					{
						try
						{
							$pluginClass = Application::getExtensionClass( $app, 'OutputPlugins', IPS::mb_ucfirst( $plugin ) );
							break;
						}
						catch( OutOfRangeException )
						{
							/* Just keep going here, because the plugin may not be part of this app */
						}
					}
				}

				/* Still doesn't exist? */
				if( ! class_exists( $pluginClass ) )
				{
					return $matches[0];
				}

				/* can be used in CSS? */
				if ( $isCSS AND $pluginClass::$canBeUsedInCss !== TRUE )
				{
					throw new InvalidArgumentException( 'invalid_plugin:' . $functionName . ' - ' . $plugin );
				}

				$code = $pluginClass::runPlugin( $value, $options, $functionName, $calledClass );

				if( !is_array( $code ) )
				{
					$code = array( 'return' => $code );
				}
				if( !isset( $code['pre'] ) )
				{
					$code['pre'] = '';
				}

				if( !isset( $code['post'] ) )
				{
					$code['post'] = '';
				}

				$return = <<<PHP
\nIPSCONTENT;\n
{$code['pre']}
PHP;
				if ( $code['return'] )
				{
					$return .= <<<PHP
\$return .= {$code['return']};
PHP;
				}
				$return .= <<<PHP
{$code['post']}
\$return .= <<<{$contentName}

PHP;
				return $return;
			},
			$function
		);

		/* Escape output */
		if ( $isCSS )
		{
			/* For the raw css content, we want to slap it inside a NOWDOC, not a HEREDOC, so that variables and backslashes are never processed. */
			$function = preg_replace( '#(\\$return\\s*(?:\\.)?=\\s*<<<)IPSCONTENT(\\n.+?\\n\\s*IPSCONTENT;(\\n|$))#si', '$1\'IPSCONTENT\'$2', $function );
		}
		else
		{
			preg_match_all( '#\$return\s{0,}(?:\.)?=\s{0,}<<<IPSCONTENT[\r\n](.+?)IPSCONTENT;(\n|\r|$)#si', $function, $matches, PREG_SET_ORDER );
			foreach ( $matches as $id => $match )
			{
				$all = $match[0];
				$content = $match[1];
				$rawFinds = array();
				$rawReplaces = array();

				if ( $isHTML === TRUE )
				{
					preg_match_all( '#\{\$([^\}]+?)\}#', $content, $varMatches, PREG_SET_ORDER );

					foreach ( $varMatches as $index => $var )
					{
						if ( stristr( $var[1], '|raw' ) )
						{
							$rawFinds[] = $var[0];
							$rawReplaces[] = str_ireplace( '|raw', '', $var[0] );
						}
						else
						{
							if ( stristr( $var[1], '|doubleencode' ) )
							{
								$replace = "\nIPSCONTENT;\n\$return .= \IPS\Theme\Template::htmlspecialchars( \$" . str_ireplace( '|doubleencode', '', $var[1] ) . ", ENT_QUOTES | ENT_DISALLOWED, 'UTF-8', TRUE );\n\$return .= <<<IPSCONTENT\n";
							}
							else
							{
								$replace = "\nIPSCONTENT;\n\$return .= \IPS\Theme\Template::htmlspecialchars( \$" . $var[1] . ", ENT_QUOTES | ENT_DISALLOWED, 'UTF-8', FALSE );\n\$return .= <<<IPSCONTENT\n";
							}

							$all = str_replace( $var[0], $replace, $all );
						}
					}
					$all = str_replace( $rawFinds, $rawReplaces, $all );

					if ( $all != $match[0] )
					{
						$function = str_replace( $match[0], $all, $function );
					}
				}
				else
				{
					$all = preg_replace( '/\{\$([^\}]+?)\|raw\}/', '{\$$1}', $all );
					$function = str_replace( $match[0], $all, $function );
				}
			}
		}

		return $function;
	}

	/**
	 * Returns a location hash for selecting templates
	 * Used when building templates to core_theme_templates_built and also when selecting
	 * from that same table.
	 *
	 * @param string $app
	 * @param string $location
	 * @param string|null $group
	 * @return    string    Md5 Key
	 */
	public static function makeBuiltTemplateLookupHash( string $app, string $location, string|null $group ): string
	{
		return md5( mb_strtolower( $app ) . ';' . mb_strtolower( $location ) . ';' . mb_strtolower( (string) $group ) );
	}
	
	/**
	 * Clears theme files from \IPS\File and the store.
	 * @note    This does not remove rows from the theme database tables.
	 *
	 * @param int $bit    Bitwise options for files to remove
	 * @param string|null	$app
	 * @return	void
	 */
	public static function clearFiles( int $bit, ?string $app=null ): void
	{
		if ( $bit & static::TEMPLATES )
		{
			static::deleteCompiledTemplate( $app );
		}

		if ( $bit & static::CSS )
		{
			static::deleteCompiledCss( $app );
		}

		if ( $bit & static::IMAGES )
		{
			static::deleteCompiledResources( $app );
		}
		
		foreach( static::themes() as $id => $theme )
		{
			/* Remove files, but don't fail if we can't */
			try
			{
				File::getClass('core_Theme')->deleteContainer( 'set_resources_' . $theme->id );
				File::getClass('core_Theme')->deleteContainer( 'css_built_' . $theme->id );
			}
			catch( Exception $e ){}
			
			/* Clear map */
			$theme->resource_map = array();
			$theme->css_map = array();
			$theme->save();
		}

		/* ACP CSS and Resources */
		Settings::i()->changeValues( array( 'acp_css_map' => '[]' ) );
		unset( Store::i()->acp_resource_map );
	}

	/**
	 * Add CSS.
	 * A check is first made to ensure we're not overwriting an existing master CSS file.
	 *
	 * @param	array $data	Data to insert (app, location, path, name, content)
	 * @return	int		Insert Id
	 *@throws	OverflowException
	 * @throws	InvalidArgumentException
	 */
	public static function addCss( array $data ): int
	{
		if ( empty( $data['app'] ) OR empty( $data['location'] ) OR empty( $data['path'] ) OR empty( $data['name'] ) )
		{
			throw new InvalidArgumentException;
		}

		/* Check for existing */
		try
		{
			$check = Db::i()->select( 'css_id', 'core_theme_css', array(
				'css_app=? AND css_location=? AND css_path=? AND css_name=LOWER(?) AND css_set_id=?',
				mb_strtolower( $data['app'] ),
				mb_strtolower( $data['location'] ),
				mb_strtolower( $data['path'] ),
				mb_strtolower( $data['name'] ),
				$data['set_id'] ?? 0
			) )->first();

			/* This exists */
			throw new OverflowException;
		}
		catch( UnderflowException $e )
		{
			/* That's ok, it doesn't exist */
		}

		/* Insert */
		$insertId = Db::i()->insert( 'core_theme_css', array(
			'css_set_id'	 => $data['set_id'] ?? 0,
			'css_app'		 => mb_strtolower( $data['app'] ),
			'css_location'   => mb_strtolower( $data['location'] ),
			'css_path'		 => mb_strtolower( $data['path'] ),
			'css_name'       => mb_strtolower( $data['name'] ),
			'css_content'    => $data['content'],
			'css_updated' 	 => time(),
			'css_version'    => Application::load('core')->long_version,
		), TRUE );

		if ( empty( $data['set_id'] ) )
		{
			Db::i()->update( 'core_themes', ['set_css_updated' => time()] );
		}

		return $insertId;
	}

	/**
	 * Add a template
	 * A check is first made to ensure we're not overwriting an existing master template bit.
	 *
	 * @param	array $data	Data to insert (app, location, group, name, variables, content)
	 * @return	int		Insert Id
	 *@throws	OverflowException
	 * @throws	InvalidArgumentException
	 */
	public static function addTemplate( array $data ): int
	{
		if ( empty( $data['app'] ) OR empty( $data['location'] ) OR empty( $data['group'] ) OR empty( $data['name'] ) )
		{
			throw new InvalidArgumentException;
		}

		/* Check for existing and there is not existing template, then it will throw an an UnderflowException */
		try
		{
			$check = Db::i()->select( 'template_id, template_set_id', 'core_theme_templates', array(
				'template_app=? AND template_location=? AND template_group=? AND LOWER(template_name)=?',
				mb_strtolower( $data['app'] ),
				mb_strtolower( $data['location'] ),
				mb_strtolower( $data['group'] ),
				mb_strtolower( $data['name'] )
			) )->first();

			/* Master bit exists, skip */
			throw new OverflowException;
		}
		catch( UnderflowException $e )
		{
			/* Template doesn't exist, so it's all good bro */
		}

		/* Insert */
		$insertId = Db::i()->insert( 'core_theme_templates', array(
			'template_set_id'	  => 0,
			'template_app'		  => $data['app'],
			'template_location'   => $data['location'],
			'template_group'	  => $data['group'],
			'template_name'       => $data['name'],
			'template_data'       => $data['variables'],
			'template_content'    => $data['content'],
			'template_updated'	  => time(),
			'template_version'    => Application::load('core')->long_version,
		), TRUE );

		static::rebuildHookPointFlags( $data['app'] );

		return $insertId;
	}

	/**
	 * Add resource
	 * Adds a resource to each theme set
	 * Theme resources should be raw binary data everywhere (filesystem and DB) except in the theme XML download where they are base64 encoded.
	 *
	 * @note	$data['content'] should be the raw binary data, not base64_encoded data
	 * @param	array $data	        Array of data (app, location, path, name, content, [plugin])
	 * @param boolean $addToMaster    Add to master set 0
	 * @return	void
	 *@throws	InvalidArgumentException
	 */
	public static function addResource( array $data, bool $addToMaster=FALSE ): void
	{
		if ( empty( $data['app'] ) OR empty( $data['location'] ) OR empty( $data['path'] ) OR empty( $data['name'] ) )
		{
			throw new InvalidArgumentException;
		}

		$name = static::makeBuiltTemplateLookupHash( $data['app'], $data['location'], $data['path'] ) . '_' . $data['name'];
				
		if ( $addToMaster )
		{
			Db::i()->insert( 'core_theme_resources', array(
                 'resource_set_id'   => 0,
                 'resource_app'      => $data['app'],
                 'resource_location' => $data['location'],
                 'resource_path'     => $data['path'],
                 'resource_name'     => $data['name'],
                 'resource_added'	  => time(),
                 'resource_filename' => NULL,
                 'resource_data'     => $data['content'],
             ) );
		}

		foreach(Theme::themes() as $id => $theme )
		{
			$resource = NULL;
			try
			{
				$resource = Db::i()->select( '*', 'core_theme_resources', array( 'resource_set_id=? and resource_app=? and resource_location=? and resource_path=? and resource_name=?', $theme->id, $data['app'], $data['location'], $data['path'], $data['name'] ) )->first();
			}
			catch( UnderflowException $ex ) { }

			if ( $resource !== NULL and isset( $resource['resource_user_edited'] ) )
			{
				if ( $resource['resource_user_edited'] )
				{
					continue;
				}
			}

			/* Clear out old rows */
			Db::i()->delete( 'core_theme_resources', array( 'resource_set_id=? and resource_app=? and resource_location=? and resource_path=? and resource_name=?', $theme->id, $data['app'], $data['location'], $data['path'], $data['name'] ) );

			$resourceMap = $theme->resource_map;
			
			if ( $data['content'] )
			{
				$saveName = $name;
				if ( $resource !== NULL and $resource['resource_data'] != $data['content'] )
				{
					/* This resource exists, so let us create a unique name to prevent cache issues */
					$ext = mb_substr( $name, ( mb_strrpos( $name, '.' ) + 1 ) );
					$saveName = mb_substr( $name, 0, ( mb_strrpos( $name, '.' ) ) ) . '_' . mt_rand() . '.' . $ext;
				}

				if( in_array( $data['app'], IPS::$ipsApps ) )
				{
					$path = '/static/resources/';
					if( !is_dir( ROOT_PATH . $path ) )
					{
						mkdir( ROOT_PATH . $path, IPS_FOLDER_PERMISSION, TRUE );
					}

					$fileName = $path . $data['app'] . '_' . $saveName;
					@file_put_contents( ROOT_PATH . $fileName, $data['content'] );
				}
				else
				{
					$fileName = (string) File::create( 'core_Theme', $saveName, $data['content'], 'set_resources_' . $theme->id, FALSE, NULL, FALSE );
				}
	
				Db::i()->insert( 'core_theme_resources', array(
						'resource_set_id'   => $theme->id,
						'resource_app'      => $data['app'],
						'resource_location' => $data['location'],
						'resource_path'     => $data['path'],
						'resource_name'     => $data['name'],
						'resource_added'	 => time(),
						'resource_filename' => $fileName,
						'resource_data'     => $data['content'],
				) );
			}
			
			$key = static::makeBuiltTemplateLookupHash($data['app'], $data['location'], $data['path']) . '_' . $data['name'];
			
			$resourceMap[ $key ] = $fileName;
			
			/* Update theme map */
			$theme->resource_map = $resourceMap;
			$theme->save();
		}
	}
	

	/**
	 * Remove templates completely from the system.
	 * Used by application manager, etc.
	 *
	 * @param string $app		Application Key
	 * @param string|null $location	Location
	 * @param string|null $group		Group
	 * @param int|null $plugin		Plugin ID
	 * @param bool $doAll		Delete all - by default only the master set is cleared
	 * @param string|null $template	Template name if you just want to do one template. If specified, deleteCompiledTemplate is NOT called and must be done manually
	 * @return	void
	 */
	public static function removeTemplates( string $app, string $location=NULL, string $group=NULL, int $plugin=NULL, bool $doAll=FALSE, string $template=NULL ): void
	{
		if ( !$template )
		{
			static::deleteCompiledTemplate( $app, $location, $group );
		}

		$where = array( array( 'template_app=?', $app ) );
		
		if ( $location !== NULL )
		{
			$where[] = array( 'template_location=?', $location );
		}
		
		if ( $group !== NULL )
		{
			$where[] = array( 'template_group=?', $group );
		}
		
		if ( $template !== NULL )
		{
			$where[] = array( 'template_name=?', $template );
		}
		
		/* Coming from build script */
		if ( !$doAll )
		{
			$where[] = array( 'template_set_id=0' );
		}
		
		Db::i()->delete( 'core_theme_templates', $where );
	}
	
	/**
	 * Remove CSS completely from the system.
	 * Used by application manager, etc.
	 *
	 * @param string $app		Application Key
	 * @param string|null $location	Location
	 * @param string|null $path		Group
	 * @param int|null $plugin		Plugin ID
	 * @param bool $doAll		Delete all - by default only the master set is cleared
	 * @param string|null $name		CSS file name if you just want to do one file. If specified, deleteCompiledCss is NOT called and must be done manually
	 * @return	void
	 */
	public static function removeCss( string $app, string $location=NULL, string $path=NULL, int $plugin=NULL, bool $doAll=FALSE, string $name=NULL ): void
	{
		if ( !$name )
		{
			static::deleteCompiledCss( $app, $location, $path );
		}

		$where = array( array( 'css_app=?', $app ) );
		
		if ( $location !== NULL )
		{
			$where[] = array( 'css_location=?', $location );
		}
		
		if ( $path !== NULL )
		{
			$where[] = array( 'css_path=?', $path );
		}
		
		if ( $name !== NULL )
		{
			$where[] = array( 'css_name=?', $name );
		}
		
		/* Coming from build script */
		if ( !$doAll )
		{
			$where[] = array( 'css_set_id=0' );
		}
		
		Db::i()->delete( 'core_theme_css', $where );
	}
	
	/**
	 * Remove resources completely from the system.
	 * Used by application manager, etc.
	 *
	 * @param string $app		Application Key
	 * @param string|null $location	Location
	 * @param string|null $path		Path
	 * @param int|null $plugin		Plugin ID
	 * @param bool $doAll		Delete all - by default only the master set is cleared
	 * @param string|null $name		Resource file name if you just want to do one file. If specified, deleteCompiledResources AND buildResourceMap are both NOT called and must be done manually
	 * @return void
	 */
	public static function removeResources( string $app, string $location=NULL, string $path=NULL, int $plugin=NULL, bool $doAll=FALSE, string $name=NULL ): void
	{
		if ( !$name )
		{
			static::deleteCompiledResources( $app, $location, $path );
		}

		$where = array( array( 'resource_app=?', $app ) );
		
		if ( $location !== NULL )
		{
			$where[] = array( 'resource_location=?', $location );
		}
		
		if ( $path !== NULL )
		{
			$where[] = array( 'resource_path=?', $path );
		}
		
		if ( $name !== NULL )
		{
			$where[] = array( 'resource_name=?', $name );
		}
		
		/* Coming from build script */
		if ( !$doAll )
		{
			$where[] = array( '(resource_set_id=0 OR resource_user_edited=0)' );
		}

		Db::i()->delete( 'core_theme_resources', $where );
		
		if ( !$name )
		{
			foreach( static::themes() as $id => $set )
			{
				$set->buildResourceMap( $app );
			}
		}
	}

	/**
	 * Remove any editor settings from this application
	 *
	 * @param string $app
	 * @return void
	 */
	public static function removeEditorSettings( string $app ) : void
	{
		Db::i()->delete( 'core_theme_editor_settings', [ 'setting_app=?', $app ] );

		/* Check the categories. If there are settings from another app, then we leave the category,
		and change the application key */
		$categories = iterator_to_array(
			Db::i()->select( '*', 'core_theme_editor_categories', [ 'cat_app=?', $app ] )
		);

		foreach( $categories as $category )
		{
			try
			{
				$otherSetting = Db::i()->select( '*', 'core_theme_editor_settings', [ 'setting_category_id=? and setting_app != ?', $category['cat_id'], $app ], 'setting_position', array( 0, 1 ) )->first();
				Db::i()->update( 'core_theme_editor_categories', [ 'cat_app' => null ], [ 'cat_id=?', $category['cat_id'] ] );
			}
			catch( UnderflowException )
			{
				Db::i()->delete( 'core_theme_editor_categories', [ 'cat_id=?', $category['cat_id'] ] );
			}
		}
	}

	/**
	 * Removes all customizations made by the theme editor except uploaded logos
	 *
	 * @return void
	 */
	public function removeThemeEditorCustomizations(): void
	{
		/* Delete the CSS file */
		Db::i()->delete( 'core_theme_css', [ 'css_set_id=? and css_location=? and css_path=? and css_name=?', $this->id, 'front', 'custom', '1-variables.css' ] );
		static::deleteCompiledCss( 'core', 'front', 'custom', '1-variables.css', Theme::i()->_id );

		/* Remove any other non logo data from the theme editor json */
		$data = $this->theme_editor_data;
		if ( is_array( $data ) )
		{
			if ( isset( $data['header'] ) )
			{
				unset( $data['header'] );
			}
		}

		$this->custom_css = '';
		$this->css_variables = null;
		$this->view_options = null;
		$this->theme_editor_data = $data;
		$this->save();
	}

	/**
	 * Because css still has {resource} tags when built, and the building is done via the ACP,
	 * Tags without a set "location" parameter are set to "admin" incorrectly.
	 *
	 * @param string $css		CSS Text
	 * @param string $location   CSS Location
	 * @return  string	Fixed CSS
	 */
	public static function fixResourceTags( string $css, string $location ): string
	{
		preg_match_all( '#\{resource=([\'"])(\S+?)\\1([^\}]+?)?\}#i', $css, $items, PREG_SET_ORDER );
	
		foreach( $items as $id => $attr )
		{
			/* Has manually added params */
			if ( isset( $attr[3] ) )
			{
				if ( ! strstr( $attr[3], 'location=' ) )
				{
					$new = str_replace( $attr[3], $attr[3] . ' location="' . $location . '"', $attr[0] );
		
					$css = str_replace( $attr[0], $new, $css );
				}
			}
			else
			{
				$new = str_replace( '}',  ' location="' . $location . '"}', $attr[0] );
				$css = str_replace( $attr[0], $new, $css );
			}
		}
	
		return $css;
	}
	
	/**
	 * Inserts a built record
	 *
	 * @param array $css	css_* table data
	 * @return File|string
	 */
	protected static function writeCss( array $css ): File|string
	{
		$css['css_path']    = ( empty( $css['css_path'] ) or $css['css_path'] == '.' ) ? '.' : $css['css_path'];
		$functionName = "css_" . $css['css_app'] . '_' . $css['css_location'] . '_' . str_replace( array( '-', '/', '.' ), '_', $css['css_path'] . '_' . $css['css_name'] );

		if ( !function_exists( $functionName ) )
		{
			static::makeProcessFunction( static::fixResourceTags( $css['css_content'], $css['css_location'] ), $functionName, '', FALSE, TRUE );
		}

		$fqFunc		= 'IPS\\Theme\\'. $functionName;
		$content	= static::minifyCss( $fqFunc() );
		$name		= static::cssFilePrefix( $css['css_app'], $css['css_location'], $css['css_path'] ) . '_' . $css['css_name'];
		
		/* Replace any <fileStore.xxx> tags in the CSS */
		Output::i()->parseFileObjectUrls( $content );

		if( in_array( $css['css_app'], IPS::$ipsApps ) AND $css['css_path'] != 'custom' )
		{
			$path = '/static/css/'; // Applications are in the root on Cloud2
			if( !is_dir( ROOT_PATH. $path ) )
			{
				mkdir( ROOT_PATH . $path, IPS_FOLDER_PERMISSION, TRUE );
			}

			$cssFileName = $path . $name;
			$result = (bool) @file_put_contents( ROOT_PATH . $cssFileName, $content, LOCK_EX );

			/* Sometimes LOCK_EX is unavailable and throws file_put_contents(): Exclusive locks are not supported for this stream.
				While we would prefer an exclusive lock, it would be better to write the file if possible. */
			if( !$result )
			{
				@unlink( ROOT_PATH . $cssFileName );
				$result = (bool) @file_put_contents( ROOT_PATH . $cssFileName, $content );
			}

			@chmod( ROOT_PATH . $cssFileName, IPS_FILE_PERMISSION );
			return $cssFileName;
		}
		
		return File::create( 'core_Theme', $name, $content, 'css_built_' . $css['css_set_id'], FALSE, NULL, FALSE );
	}
	
	/**
	 * Minifies CSS
	 *
	 * @param string $content	Content to minify
	 * @return	string  $content	Minified
	 */
	public static function minifyCss( string $content ): string
	{
		/* Comments */
		$content = preg_replace( '#/\*[^*]*\*+([^/][^*]*\*+)*/#', '', $content );
	
		/* Multiple spaces, tabs and newlines */
		$content = str_replace( array( "\r\n", "\r", "\n", "\t" ), ' ', $content );
		$content = preg_replace( '!\s+!', ' ', $content );

		/* Some more space removal */
		$content = str_replace( ' {', '{', $content );
		$content = str_replace( '{ ', '{', $content );
		$content = str_replace( ' }', '}', $content );
		$content = str_replace( '} ', '}', $content );
		$content = str_replace( '; ', ';', $content );
		$content = str_replace( ': ', ':', $content );
	
		return $content;
	}
	
	/**
	 * This method is executed when theme settings have changed from saveForm()
	 *
	 * @param int $setId		Theme set id
	 * @erturn  void
	 */
	public static function themeSettingsHaveChanged( int $setId ): void
	{
		if( Application::appIsEnabled( 'cms' ) )
		{
			Page::deleteCachedIncludes();
		}

		$themeSetToBuild = static::load( $setId );
		
		File::getClass('core_Theme')->deleteContainer( 'css_built_' . $themeSetToBuild->id );
		$themeSetToBuild->css_map = array();
	}
	
	/**
	 * Any custom images/css/templates?
	 *
	 * @return boolean
	 */
	public function isCustomized(): bool
	{
		if( $this->theme_editor_data !== static::$defaultThemeEditorData )
		{
			return true;
		}
		
		if ( $this->custom_css )
		{
			return true;
		}

		if ( $this->css_variables )
		{
			return true;
		}

		if( $this->view_options )
		{
			return true;
		}

		return false;
	}

	/**
	 * Cache bust key for CSS
	 *
	 * @return string
	 */
	public function cssCacheBustKey(): string
	{
		return CACHEBUST_KEY . $this->css_updated;
	}

	/**
	 * Add a lock to prevent race conditions
	 *
	 * @param	string	$key 	Unique key to sign the lock
	 * @return	void
	 */
	public static function lock( string $key ): void
	{
		Db::i()->replace( 'core_cache', array(
			'cache_key'    => 'locking_' . $key,
			'cache_value'  => $key,
			'cache_expire' => time() + 30
		) );
	}
	
	/**
	 * Check a race condition lock
	 *
	 * @param	string	$key 	Unique key to sign the lock
	 * @return	boolean
	 */
	public static function checkLock( string $key ): bool
	{
		try
		{
			Db::i()->select( 'SQL_NO_CACHE *', 'core_cache', array( 'cache_key=? and cache_expire > ?', 'locking_' . $key, time() - 30 ), NULL, NULL, NULL, NULL, Db::SELECT_FROM_WRITE_SERVER )->first();
			return true;
		}
		catch( Exception $ex )
		{
			return false;
		} 
	}
	
	/**
	 * Remove a race condition lock
	 *
	 * @param	string	$key 	Unique key to sign the lock
	 * @return	void
	 */
	public static function unlock( string $key ): void
	{
		Db::i()->delete( 'core_cache', array( 'cache_key=?', 'locking_' . $key ) );
	}

	/**
	 * Get the hook names from a template's contents
	 *
	 * @param string $contents
	 * @return array
	 */
	public static function extractHookNames( string $contents ): array
	{
		$names = [];
		preg_match_all( '/data-ips-hook=["\']([^"\']+)["\']/', $contents, $matches, PREG_SET_ORDER );

		if ( $matches and is_array( $matches ) and count( $matches ) )
		{
			foreach( $matches as $index => $match )
			{
				$names[] = $match[1];
			}
		}

		return $names;
	}

	/**
	 * Start a theme editing session
	 *
	 * @param Member|null $member
	 * @return void
	 */
	public function editingStart( ?Member $member=null ): void
	{
		$member = $member ?: Member::loggedIn();
		$member->members_bitoptions['bw_using_skin_gen'] = 1;
		$member->save();

		$this->edit_in_progress = $member->member_id;
		$this->save();

		/* Start a session with any existing custom CSS; this way we can tell if it's been deleted */
		Db::i()->replace( 'core_theme_editor_sessions', [ 'member_id' => $member->member_id, 'custom_css' => $this->custom_css, 'set_id' => $this->id ] );
	}

	/**
	 * Finish a theme editing session
	 *
	 * @param Member|null $member
	 * @return void
	 */
	public function editingFinish( ?Member $member=null ): void
	{
		$member = $member ?: Member::loggedIn();
		$member->members_bitoptions['bw_using_skin_gen'] = 0;
		$member->save();

		$this->edit_in_progress = 0;
		$this->save();

		/* Clear ALL sessions so that everyone gets the latest data */
		Db::i()->delete( 'core_theme_editor_sessions', [ 'set_id=?', $this->id ] );
	}

	/**
	 * @var array|null
	 */
	protected ?array $_session = null;

	/**
	 * Load data for the current session
	 *
	 * @param Member|null $member
	 * @return array
	 */
	protected function editingSessionData( ?Member $member=null ) : array
	{
		$member = $member ?: Member::loggedIn();
		if( !$member->isEditingTheme() )
		{
			return [];
		}

		if( $this->_session === null )
		{
			try
			{
				$row = Db::i()->select( "*", 'core_theme_editor_sessions', [ 'member_id=? and set_id=?', $member->member_id, $this->_id ] )->first();
				$row['data'] = !empty( $row['data'] ) ? json_decode( $row['data'], true ) : [];
				$this->_session = $row;
			}
			catch( UnderflowException )
			{
				$this->_session = [];
			}
		}

		return $this->_session;
	}

	/**
	 * Clear out the session data for this member
	 *
	 * @param Member|null $member
	 * @return void
	 */
	public function clearEditingSession( ?Member $member=null ) : void
	{
		$member = $member ?: Member::loggedIn();
		if( $member->isEditingTheme() )
		{
			Db::i()->delete( 'core_theme_editor_sessions', [ 'member_id=? and set_id=?', $member->member_id, $this->id ] );
		}
	}

	/**
	 * Update the custom CSS that we have stored in the session
	 *
	 * @param string $css
	 * @param Member|null $member
	 * @return void
	 */
	public function updateSessionCss( string $css, ?Member $member=null ) : void
	{
		$member = $member ?: Member::loggedIn();

		if( !empty( $this->editingSessionData( $member ) ) )
		{
			Db::i()->update( 'core_theme_editor_sessions', [ 'custom_css' => $css ], [ 'member_id=?', $member->member_id ] );
		}
		else
		{
			Db::i()->replace( 'core_theme_editor_sessions', [
				'member_id' => $member->member_id,
				'set_id' => $this->id,
				'custom_css' => $css
			] );
		}

		$this->_session = null;
	}

	/**
	 * Update any changed settings
	 *
	 * @param array $vars
	 * @param Member|null $member
	 * @return void
	 */
	public function updateSessionVars( array $vars, ?Member $member=null ) : void
	{
		$member = $member ?: Member::loggedIn();
		if( !empty( $this->editingSessionData( $member ) ) )
		{
			Db::i()->update( 'core_theme_editor_sessions', [ 'data' => json_encode( $vars ) ], [ 'member_id=?', $member->member_id ] );
		}
		else
		{
			Db::i()->replace( 'core_theme_editor_sessions', [
				'member_id' => $member->member_id,
				'set_id' => $this->id,
				'data' => json_encode( $vars )
			] );
		}

		$this->_session = null;
	}

	/**
	 * Return the theme that is currently being edited by this member
	 * If no theme is being edited, an OutOfRangeException is thrown
	 *
	 * @param Member|null $member
	 * @return Theme
	 */
	public static function byEditingMember( ?Member $member=null ): Theme
	{
		$member = $member ?: Member::loggedIn();

		try
		{
			return static::load( $member->member_id, 'set_edit_in_progress' );
		}
		catch( Exception )
		{
			/* No theme currently being edited by this member, so turn off editing */
			$member->members_bitoptions['bw_using_skin_gen'] = 0;
			$member->save();

			throw new OutOfRangeException();
		}
	}
}