<?php

/**
 * @brief        Setting
 * @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
 * @subpackage
 * @since        2/14/2024
 */

namespace IPS\Theme\Editor;

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

use InvalidArgumentException;
use IPS\Application;
use IPS\Data\Store;
use IPS\Db;
use IPS\Dispatcher;
use IPS\Helpers\Form;
use IPS\Helpers\Form\Codemirror;
use IPS\Helpers\Form\Node;
use IPS\Helpers\Form\Number;
use IPS\Helpers\Form\Select;
use IPS\Helpers\Form\Text;
use IPS\Helpers\Form\YesNo;
use IPS\Http\Url;
use IPS\IPS;
use IPS\Lang;
use IPS\Member;
use IPS\Node\Model;
use IPS\Patterns\ActiveRecordIterator;
use IPS\Request;
use IPS\Settings;
use IPS\Theme;
use OutOfRangeException;
use const IPS\IN_DEV;
use function str_replace;
use function trim;
use function ucwords;

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

class Setting extends Model
{
	const SETTING_TEXT = 'text';
	const SETTING_NUMBER = 'range';
	const SETTING_COLOR = 'color';
	const SETTING_SELECT = 'select';
	const SETTING_CHECKBOX = 'checkbox';
	const SETTING_IMAGE = 'image';

	/**
	 * @brief	[ActiveRecord] Multiton Store
	 * @note	This needs to be declared in any child classes as well, only declaring here for editor code-complete/error-check functionality
	 */
	protected static array $multitons	= array();

	/**
	 * @brief	[ActiveRecord] Database Prefix
	 */
	public static string $databasePrefix = 'setting_';

	/**
	 * @brief	[ActiveRecord] Database table
	 * @note	This MUST be over-ridden
	 */
	public static ?string $databaseTable	= 'core_theme_editor_settings';

	/**
	 * @var array
	 */
	protected static array $multitonMap = array();

	/**
	 * @brief	[ActiveRecord] Database ID Fields
	 * @note	If using this, declare a static $multitonMap = array(); in the child class to prevent duplicate loading queries
	 */
	protected static array $databaseIdFields = array( 'setting_key' );

	/**
	 * @brief       [Node] Order Database Column
	 */
	public static ?string $databaseColumnOrder = 'position';

	/**
	 * @brief	[Node] Parent Node ID Database Column
	 */
	public static string $parentNodeColumnId = 'category_id';

	/**
	 * @brief	[Node] Parent Node Class
	 */
	public static string $parentNodeClass = Category::class;

	/**
	 * @brief	[Node] Node Title
	 */
	public static string $nodeTitle = 'theme_editor_settings';

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

	/**
	 * @brief	[ActiveRecord] Attempt to load from cache
	 * @note	If this is set to TRUE you should define a getStore() method to return the objects from cache
	 */
	protected static bool $loadFromCache = true;

	/**
	 * [Node] Get Title
	 *
	 * @return	string
	 */
	protected function get__title(): string
	{
		return $this->name ?? '';
	}

	/**
	 * Get the title for a node using the specified language object
	 * This is commonly used where we cannot use the logged in member's language, such as sending emails
	 *
	 * @param Lang $language	Language object to fetch the title with
	 * @param array $options	What options to use for language parsing
	 * @return	string
	 */
	public function getTitleForLanguage( Lang $language, array $options=array() ): string
	{
		return $this->_title;
	}

	/**
	 * [Node] Get Title language key, not added to a language stack
	 *
	 * @return	string|null
	 */
	protected function get__titleLanguageKey(): ?string
	{
		return $this->_title;
	}

	/**
	 * [Node] Get content table description
	 *
	 * @return	string|null
	 */
	protected function get_description(): ?string
	{
		return $this->desc;
	}

	/**
	 * Attempt to load cached data
	 *
	 * @note	This should be overridden in your class if you define $cacheToLoadFrom
	 * @return    array
	 */
	public static function getStore(): array
	{
		try
		{
			$cache = Store::i()->themeEditorSettings;
		}
		catch( OutOfRangeException )
		{
			$cache = iterator_to_array( Db::i()->select( '*', static::$databaseTable, NULL, static::$databasePrefix . static::$databaseColumnId )->setKeyField( static::$databasePrefix . static::$databaseColumnId ) );
			Store::i()->themeEditorSettings = $cache;
		}

		return $cache;
	}

	/**
	 * Load all the setting into the cache in one shot
	 *
	 * @return void
	 */
	public static function loadAllKeys() : void
	{
		if( !isset( static::$multitonMap[ 'setting_key'] ) )
		{
			foreach( new ActiveRecordIterator(
				Db::i()->select( '*', static::$databaseTable ),
				Setting::class
					 ) as $setting )
			{
				if( !isset( static::$multitons[ $setting->id ] ) )
				{
					static::$multitons[ $setting->id ] = $setting;
				}

				/* Color settings will be called with the prefix light__ or dark__ */
				if( $setting->type == static::SETTING_COLOR )
				{
					static::$multitonMap['setting_key'][ 'dark__' . $setting->key ] = $setting->id;
					static::$multitonMap['setting_key'][ 'light__' . $setting->key ] = $setting->id;
				}
				static::$multitonMap['setting_key'][ $setting->key ] = $setting->id;
			}
		}
	}

	/**
	 * Fetch All Root Nodes
	 *
	 * @param string|null $permissionCheck	The permission key to check for or NULl to not check permissions
	 * @param Member|null $member				The member to check permissions for or NULL for the currently logged in member
	 * @param mixed $where				Additional WHERE clause
	 * @param array|null $limit				Limit/offset to use, or NULL for no limit (default)
	 * @return	Model[]
	 */
	public static function roots( ?string $permissionCheck='view', Member $member=NULL, mixed $where=array(), array $limit=NULL ): array
	{
		$return = parent::roots( $permissionCheck, $member, $where, $limit );
		static::loadAllKeys();
		return $return;
	}

	/**
	 * @return array
	 */
	public function get_data() : array
	{
		return isset( $this->_data['data'] ) ? json_decode( $this->_data['data'], true ) : array();
	}

	/**
	 * @return array|string
	 */
	public function get_default() : array|string
	{
		if( $this->type == static::SETTING_COLOR )
		{
			return isset( $this->_data['default'] ) ? json_decode( $this->_data['default'], true ) : array( 'light' => '', 'dark' => '' );
		}
		elseif( $this->type == static::SETTING_NUMBER )
		{
			return ( isset( $this->_data['default'] ) and is_numeric( $this->_data['default'] ) ) ? $this->_data['default'] : 0;
		}

		return $this->_data['default'] ?? '';
	}

	/**
	 * @param array|string|null $val
	 * @return void
	 */
	public function set_data( array|string|null $val ) : void
	{
		if( is_array( $val ) )
		{
			$val = json_encode( $val );
		}

		$this->_data['data'] = $val ?: null;
	}

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

	/**
	 * Load all settings for a given category
	 *
	 * @param Category $category
	 * @return array
	 */
	public static function loadByCategory( Category $category ) : array
	{
		if( static::$appKeys === null )
		{
			static::$appKeys = array_keys( Application::enabledApplications() );
		}

		$themesToCheck = [ Theme::i()->id, Theme::$defaultFrontendThemeSet ];

		/* If we are working with a specific theme, make sure we allow it */
		if( isset( Request::i()->set_id ) )
		{
			$themesToCheck[] = Request::i()->set_id;
		}

		$return = [];
		foreach( new ActiveRecordIterator(
			Db::i()->select( '*', static::$databaseTable, array( 'setting_category_id=?', $category->id ), 'setting_position' ),
			Setting::class
				 ) as $setting )
		{
			/* Make sure we only use enabled applications */
			if( $setting->app and !in_array( $setting->app, static::$appKeys ) )
			{
				continue;
			}

			if( $setting->set_id and !in_array( $setting->set_id, $themesToCheck ) )
			{
				continue;
			}

			$return[] = $setting;
		}
		return $return;
	}

	/**
	 * Figure out the value for this setting
	 *
	 * @param bool $forceDefault
	 * @return string|array
	 */
	public function value( bool $forceDefault=false ): string|array
	{
		/* Do we have an override in the theme? */
		$overrides = $forceDefault ? [] : Theme::i()->getCssVariables( Theme::CUSTOM_ONLY );

		switch( $this->type )
		{
			case static::SETTING_COLOR:
				if( $forceDefault )
				{
					return [
						'light' => $this->default['light'],
						'dark' => $this->default['dark']
					];
				}

				return [
					'light' => ( $overrides['light__' . $this->key ] ?? $this->default['light'] ),
					'dark' => ( $overrides['dark__' . $this->key ] ?? $this->default['dark'] )
				];

			default:
				if( $forceDefault )
				{
					$value = $this->default;
				}
				else
				{
					$value = $overrides[ $this->key ] ?? $this->default;
				}

				if( empty( $value ) and $value != 0 )
				{
					return "";
				}

				/* If we have an override,then the admin manually entered text, so treat it
				as plain text */
				if( !$forceDefault and $this->type == static::SETTING_TEXT and isset( $overrides[ $this->key ] ) )
				{
					$value = htmlentities( $value, ENT_QUOTES );
				}

				$functionName = 'setting__' . ( $forceDefault ? 'default__' : '' ) . str_replace( '-', '_', $this->key );
				Theme::runProcessFunction( Theme::_compileTemplate( $value, $functionName ), $functionName );
				$settingFunction = 'IPS\\Theme\\' . $functionName;
				$return = $settingFunction();
				return trim( $return );
		}
	}

	/**
	 * Wrapper to make things easier in the templates
	 *
	 * @return string|array
	 */
	public function defaultValue() : string|array
	{
		return $this->value( true );
	}

	/**
	 * Parse the value of this setting based on the setting type
	 *
	 * @param string|null $value
	 * @return string|int
	 */
	public function parsedValue( ?string $value ) : string|int
	{
		if( $value === null )
		{
			$value = Theme::i()->getCssVariables()[ $this->key ] ?? '';
		}

		switch( $this->type )
		{
			case static::SETTING_TEXT:
				$value = strip_tags( $value );
				$value = str_replace( array( '/*', ';' ), '', $value );
				$value = htmlentities( $value );
				break;

			case static::SETTING_CHECKBOX:
				$value = (int) $value;
				break;

			case static::SETTING_IMAGE:
				/* Handle Resource URLs */
				preg_match( '/\{([a-z]+?=([\'"]).+?\\2 ?+)}/', $value, $matches );
				if( count( $matches ) )
				{
					preg_match_all( '/(.+?)="([^"]*)"\s?/', $matches[1], $submatches );
					if( count( $submatches[0] ) and $submatches[1][0] == 'resource' )
					{
						$resourceName = array_shift( $submatches[2] );
						array_shift( $submatches[1] );
						$options = array();
						foreach ( $submatches[1] as $k => $v )
						{
							$options[ $v ] = $submatches[2][ $k ];
						}

						$value = (string) ( Theme::i()->resource( $resourceName, $options['app'] ?? 'core', $options['location'] ?? 'front' )?->url );
					}
				}
				break;
		}

		return $value;
	}

	/**
	 * @return string
	 */
	public function editorHtml() : string
	{
		$template = 'setting' . ucwords( $this->type );
		return Theme::i()->getTemplate( 'themeeditor', 'core', 'front' )->$template( $this );
	}

	/**
	 * [Node] Get buttons to display in tree
	 * Example code explains return value
	 *
	 * @code
	 * array(
	 * array(
	 * 'icon'	=>	'plus-circle', // Name of FontAwesome icon to use
	 * 'title'	=> 'foo',		// Language key to use for button's title parameter
	 * 'link'	=> \IPS\Http\Url::internal( 'app=foo...' )	// URI to link to
	 * 'class'	=> 'modalLink'	// CSS Class to use on link (Optional)
	 * ),
	 * ...							// Additional buttons
	 * );
	 * @endcode
	 * @param Url $url		Base URL
	 * @param	bool	$subnode	Is this a subnode?
	 * @return	array
	 */
	public function getButtons( Url $url, bool $subnode=FALSE ): array
	{
		$buttons = parent::getButtons( $url, $subnode );

		/* Don't allow modification of 1st party settings if we're in theme designer mode */
		if( !IN_DEV and Settings::i()->theme_designer_mode and !$this->set_id and in_array( $this->app, IPS::$ipsApps ) )
		{
			unset( $buttons['edit'] );
			unset( $buttons['delete'] );
		}

		return $buttons;
	}

	/**
	 * [Node] Add/Edit Form
	 *
	 * @param	Form	$form	The form
	 * @return	void
	 */
	public function form( Form &$form ) : void
	{
		$form->add( new Text( 'themeeditor_setting_name', $this->name, true ) );
		$form->add( new Text( 'themeeditor_setting_desc', $this->desc, false ) );
		$form->add( new Text( 'themeeditor_setting_key', $this->key, true, array(
			'regex' => '/[A-Z]+/i',
			'maxLength' => 150
		), function( $val ){
			try
			{
				$test = static::load( $val, 'setting_key' );
				if( $test->id != $this->id )
				{
					throw new InvalidArgumentException( "err__duplicate_setting_key" );
				}
			}
			catch( OutOfRangeException ){}
		}) );

		$form->add( new Node( 'themeeditor_setting_category', $this->parent(), true, array(
			'class' => Category::class,
			'multiple' => false,
			'subnodes' => false,
			'permissionCheck' => function( $val ){
				if( $val instanceof Category and ( $val->hasSettings() or $val->hasColors() or !$val->hasContents() ) )
				{
					return true;
				}
				return false;
			}
		) ) );

		$form->add( new Select( 'themeeditor_setting_type', $this->type, true, array(
			'options' => array(
				static::SETTING_COLOR => 'themeeditor_setting_type__' . static::SETTING_COLOR,
				static::SETTING_TEXT => 'themeeditor_setting_type__' . static::SETTING_TEXT,
				static::SETTING_NUMBER => 'themeeditor_setting_type__' . static::SETTING_NUMBER,
				static::SETTING_SELECT => 'themeeditor_setting_type__' . static::SETTING_SELECT,
				static::SETTING_CHECKBOX => 'themeeditor_setting_type__' . static::SETTING_CHECKBOX,
				static::SETTING_IMAGE => 'themeeditor_setting_type__' . static::SETTING_IMAGE
			),
			'toggles' => array(
				static::SETTING_COLOR => array( 'themeeditor_setting_light_default', 'themeeditor_setting_dark_default' ),
				static::SETTING_TEXT => array( 'themeeditor_setting_default' ),
				static::SETTING_NUMBER => array( 'themeeditor_setting_min', 'themeeditor_setting_max', 'themeeditor_setting_step', 'themeeditor_setting_default' ),
				static::SETTING_SELECT => array( 'themeeditor_setting_options', 'themeeditor_setting_default' ),
				static::SETTING_CHECKBOX => array( 'themeeditor_setting_default' ),
				static::SETTING_IMAGE => array( 'themeeditor_setting_default' )
			)
		) ) );

		$form->add( new Number( 'themeeditor_setting_min', $this->data['min'] ?? null, false, array(
			'decimals' => 2
		), null, null, null, 'themeeditor_setting_min' ) );
		$form->add( new Number( 'themeeditor_setting_max', $this->data['max'] ?? null, null, array(
			'decimals' => 2
		), null, null, null, 'themeeditor_setting_max' ) );
		$form->add( new Number( 'themeeditor_setting_step', $this->data['step'] ?? 1, false, array(
			'decimals' => 2
		), null, null, null, 'themeeditor_setting_step' ) );

		$options = [];
		if( isset( $this->data['options'] ) )
		{
			foreach( $this->data['options'] as $opt )
			{
				$options[] = [ 'key' => $opt[0], 'value' => $opt[1] ];
			}
		}
		$form->add( new Form\Stack( 'themeeditor_setting_options', count( $options ) ? $options : null, null, array(
			'stackFieldType' => 'KeyValue'
		), null, null, null, 'themeeditor_setting_options' ) );

		$form->add( new Codemirror( 'themeeditor_setting_default', is_array( $this->default ) ? null : $this->default, false, array(
			'height' => 100
		), null, null, null, 'themeeditor_setting_default' ) );
		$form->add( new Codemirror( 'themeeditor_setting_light_default', is_array( $this->default ) ? $this->default['light'] : null, false, array(
			'height' => 100
		), null, null, null, 'themeeditor_setting_light_default' ) );
		$form->add( new Codemirror( 'themeeditor_setting_dark_default', is_array( $this->default ) ? $this->default['dark'] : null, false, array(
			'height' => 100
		), null, null, null, 'themeeditor_setting_dark_default' ) );

		$form->add( new YesNo( 'themeeditor_setting_refresh', $this->refresh, false ) );

		if( Dispatcher::i()->module->key == 'customization' )
		{
			$form->add( new Node( 'themeeditor_setting_app', $this->app ? Application::load( $this->app ) : Application::load( 'core' ), true, array(
				'class' => Application::class,
				'multiple' => false,
				'subnodes' => false
			) ) );
		}
	}

	/**
	 * [Node] Format form values from add/edit form for save
	 *
	 * @param	array	$values	Values from the form
	 * @return	array
	 */
	public function formatFormValues( array $values ): array
	{
		if( !$this->id )
		{
			$this->save();
		}

		$return = [
			'setting_name' => $values['themeeditor_setting_name'],
			'setting_desc' => $values['themeeditor_setting_desc'],
			'setting_key' => $values['themeeditor_setting_key'],
			'setting_category_id' => ( $values['themeeditor_setting_category'] instanceof Category ) ? $values['themeeditor_setting_category']->id : 0,
			'setting_type' => $values['themeeditor_setting_type'],
			'setting_set_id' => Request::i()->set_id ?? 0,
			'setting_refresh' => (bool) $values['themeeditor_setting_refresh']
		];

		switch( $values['themeeditor_setting_type' ] )
		{
			case static::SETTING_COLOR:
				$return['setting_default'] = json_encode( array(
					'light' => $values['themeeditor_setting_light_default'],
					'dark' => $values['themeeditor_setting_dark_default']
				) );
				break;

			case static::SETTING_SELECT:
				$options = [];
				foreach( $values['themeeditor_setting_options'] as $opt )
				{
					$options[] = [ $opt['key'], $opt['value'] ];
				}
				$return['setting_data'] = array( 'options' => $options );
				$return['setting_default'] = $values['themeeditor_setting_default'];
				break;

			case static::SETTING_NUMBER:
				$return['setting_data'] = array(
					'min' => $values['themeeditor_setting_min'],
					'max' => $values['themeeditor_setting_max'],
					'step' => $values['themeeditor_setting_step']
				);
				$return['setting_default'] = $values['themeeditor_setting_default'];
				break;

			default:
				$return['setting_default'] = $values['themeeditor_setting_default'];
				break;
		}

		if( isset( $values['themeeditor_setting_app'] ) and $values['themeeditor_setting_app'] instanceof  Application )
		{
			$return['setting_app'] = $values['themeeditor_setting_app']->directory;
		}

		return $return;
	}
}