/*
 * Copyright 2019 FormDev Software GmbH
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.formdev.flatlaf;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Insets;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.function.Function;
import javax.swing.UIDefaults;
import javax.swing.UIManager;
import javax.swing.UIDefaults.ActiveValue;
import javax.swing.UIDefaults.LazyValue;
import javax.swing.plaf.ColorUIResource;
import javax.swing.plaf.DimensionUIResource;
import javax.swing.plaf.InsetsUIResource;
import com.formdev.flatlaf.ui.FlatEmptyBorder;
import com.formdev.flatlaf.ui.FlatLineBorder;
import com.formdev.flatlaf.util.ColorFunctions;
import com.formdev.flatlaf.util.ColorFunctions.ColorFunction;
import com.formdev.flatlaf.util.DerivedColor;
import com.formdev.flatlaf.util.GrayFilter;
import com.formdev.flatlaf.util.HSLColor;
import com.formdev.flatlaf.util.LoggingFacade;
import com.formdev.flatlaf.util.StringUtils;
import com.formdev.flatlaf.util.SystemInfo;
import com.formdev.flatlaf.util.UIScale;

/**
 * Load UI defaults from properties files associated to Flat LaF classes and add to UI defaults.
 *
 * Each class that extend the LaF class may have its own .properties file
 * in the same package as the class. Properties from superclasses are loaded
 * first to give subclasses a chance to override defaults.
 * E.g. if running FlatDarkLaf, then the FlatLaf.properties is loaded first
 * and FlatDarkLaf.properties loaded second.
 *
 * @author Karl Tauber
 */
class UIDefaultsLoader
{
	private static final String TYPE_PREFIX = "{";
	private static final String TYPE_PREFIX_END = "}";
	private static final String VARIABLE_PREFIX = "@";
	private static final String PROPERTY_PREFIX = "$";
	private static final String OPTIONAL_PREFIX = "?";
	private static final String WILDCARD_PREFIX = "*.";

	private static int parseColorDepth;

	static void loadDefaultsFromProperties( Class<?> lookAndFeelClass, List<FlatDefaultsAddon> addons,
		Properties additionalDefaults, boolean dark, UIDefaults defaults )
	{
		// determine classes in class hierarchy in reverse order
		ArrayList<Class<?>> lafClasses = new ArrayList<>();
		for( Class<?> lafClass = lookAndFeelClass;
			FlatLaf.class.isAssignableFrom( lafClass );
			lafClass = lafClass.getSuperclass() )
		{
			lafClasses.add( 0, lafClass );
		}

		loadDefaultsFromProperties( lafClasses, addons, additionalDefaults, dark, defaults );
	}

	static void loadDefaultsFromProperties( List<Class<?>> lafClasses, List<FlatDefaultsAddon> addons,
		Properties additionalDefaults, boolean dark, UIDefaults defaults )
	{
		try {
			// load core properties files
			Properties properties = new Properties();
			for( Class<?> lafClass : lafClasses ) {
				String propertiesName = '/' + lafClass.getName().replace( '.', '/' ) + ".properties";
				try( InputStream in = lafClass.getResourceAsStream( propertiesName ) ) {
					if( in != null )
						properties.load( in );
				}
			}

			// load properties from addons
			for( FlatDefaultsAddon addon : addons ) {
				for( Class<?> lafClass : lafClasses ) {
					try( InputStream in = addon.getDefaults( lafClass ) ) {
						if( in != null )
							properties.load( in );
					}
				}
			}

			// collect addon class loaders
			List<ClassLoader> addonClassLoaders = new ArrayList<>();
			for( FlatDefaultsAddon addon : addons ) {
				ClassLoader addonClassLoader = addon.getClass().getClassLoader();
				if( !addonClassLoaders.contains( addonClassLoader ) )
					addonClassLoaders.add( addonClassLoader );
			}

			// load custom properties files (usually provided by applications)
			List<Object> customDefaultsSources = FlatLaf.getCustomDefaultsSources();
			int size = (customDefaultsSources != null) ? customDefaultsSources.size() : 0;
			for( int i = 0; i < size; i++ ) {
				Object source = customDefaultsSources.get( i );
				if( source instanceof String && i + 1 < size ) {
					// load from package in classloader
					String packageName = (String) source;
					ClassLoader classLoader = (ClassLoader) customDefaultsSources.get( ++i );

					// use class loader also for instantiating classes specified in values
					if( classLoader != null && !addonClassLoaders.contains( classLoader ) )
						addonClassLoaders.add( classLoader );

					packageName = packageName.replace( '.', '/' );
					if( classLoader == null )
						classLoader = FlatLaf.class.getClassLoader();

					for( Class<?> lafClass : lafClasses ) {
						String propertiesName = packageName + '/' + lafClass.getSimpleName() + ".properties";
						try( InputStream in = classLoader.getResourceAsStream( propertiesName ) ) {
							if( in != null )
								properties.load( in );
						}
					}
				} else if( source instanceof File ) {
					// load from folder
					File folder = (File) source;
					for( Class<?> lafClass : lafClasses ) {
						File propertiesFile = new File( folder, lafClass.getSimpleName() + ".properties" );
						if( !propertiesFile.isFile() )
							continue;

						try( InputStream in = new FileInputStream( propertiesFile ) ) {
							properties.load( in );
						}
					}
				}
			}

			// add additional defaults
			if( additionalDefaults != null )
				properties.putAll( additionalDefaults );

			// collect all platform specific keys (but do not modify properties)
			ArrayList<String> platformSpecificKeys = new ArrayList<>();
			for( Object okey : properties.keySet() ) {
				String key = (String) okey;
				if( key.startsWith( "[" ) &&
					(key.startsWith( "[win]" ) ||
					 key.startsWith( "[mac]" ) ||
					 key.startsWith( "[linux]" ) ||
					 key.startsWith( "[light]" ) ||
					 key.startsWith( "[dark]" )) )
				  platformSpecificKeys.add( key );
			}

			// remove platform specific properties and re-add only properties
			// for current platform, but with platform prefix removed
			if( !platformSpecificKeys.isEmpty() ) {
				// handle light/dark specific properties
				String lightOrDarkPrefix = dark ? "[dark]" : "[light]";
				for( String key : platformSpecificKeys ) {
					if( key.startsWith( lightOrDarkPrefix ) )
						properties.put( key.substring( lightOrDarkPrefix.length() ), properties.remove( key ) );
				}

				// handle platform specific properties
				String platformPrefix =
					SystemInfo.isWindows ? "[win]" :
					SystemInfo.isMacOS ? "[mac]" :
					SystemInfo.isLinux ? "[linux]" : "[unknown]";
				for( String key : platformSpecificKeys ) {
					Object value = properties.remove( key );
					if( key.startsWith( platformPrefix ) )
						properties.put( key.substring( platformPrefix.length() ), value );
				}
			}

			// get (and remove) wildcard replacements, which override all other defaults that end with same suffix
			HashMap<String, String> wildcards = new HashMap<>();
			Iterator<Entry<Object, Object>> it = properties.entrySet().iterator();
			while( it.hasNext() ) {
				Entry<Object, Object> e = it.next();
				String key = (String) e.getKey();
				if( key.startsWith( WILDCARD_PREFIX ) ) {
					wildcards.put( key.substring( WILDCARD_PREFIX.length() ), (String) e.getValue() );
					it.remove();
				}
			}

			// override UI defaults with wildcard replacements
			for( Object key : defaults.keySet() ) {
				int dot;
				if( !(key instanceof String) ||
					properties.containsKey( key ) ||
					(dot = ((String)key).lastIndexOf( '.' )) < 0 )
				  continue;

				String wildcardKey = ((String)key).substring( dot + 1 );
				String wildcardValue = wildcards.get( wildcardKey );
				if( wildcardValue != null )
					properties.put( key, wildcardValue );
			}

			Function<String, String> propertiesGetter = key -> {
				return properties.getProperty( key );
			};
			Function<String, String> resolver = value -> {
				return resolveValue( value, propertiesGetter );
			};

			// parse and add properties to UI defaults
			for( Map.Entry<Object, Object> e : properties.entrySet() ) {
				String key = (String) e.getKey();
				if( key.startsWith( VARIABLE_PREFIX ) )
					continue;

				String value = resolveValue( (String) e.getValue(), propertiesGetter );
				try {
					defaults.put( key, parseValue( key, value, null, resolver, addonClassLoaders ) );
				} catch( RuntimeException ex ) {
					logParseError( key, value, ex, true );
				}
			}
		} catch( IOException ex ) {
			LoggingFacade.INSTANCE.logSevere( "FlatLaf: Failed to load properties files.", ex );
		}
	}

	static void logParseError( String key, String value, RuntimeException ex, boolean severe ) {
		String message = "FlatLaf: Failed to parse: '" + key + '=' + value + '\'';
		if( severe )
			LoggingFacade.INSTANCE.logSevere( message, ex );
		else
			LoggingFacade.INSTANCE.logConfig( message, ex );
	}

	static String resolveValue( String value, Function<String, String> propertiesGetter ) {
		value = value.trim();
		String value0 = value;

		if( value.startsWith( PROPERTY_PREFIX ) )
			value = value.substring( PROPERTY_PREFIX.length() );
		else if( !value.startsWith( VARIABLE_PREFIX ) )
			return value;

		boolean optional = false;
		if( value.startsWith( OPTIONAL_PREFIX ) ) {
			value = value.substring( OPTIONAL_PREFIX.length() );
			optional = true;
		}

		String newValue = propertiesGetter.apply( value );
		if( newValue == null ) {
			if( optional )
				return "null";

			throw new IllegalArgumentException( "variable or property '" + value + "' not found" );
		}

		if( newValue.equals( value0 ) )
			throw new IllegalArgumentException( "endless recursion in variable or property '" + value + "'" );

		return resolveValue( newValue, propertiesGetter );
	}

	enum ValueType { UNKNOWN, STRING, BOOLEAN, CHARACTER, INTEGER, FLOAT, BORDER, ICON, INSETS, DIMENSION, COLOR,
		SCALEDINTEGER, SCALEDFLOAT, SCALEDINSETS, SCALEDDIMENSION, INSTANCE, CLASS, GRAYFILTER, NULL, LAZY }

	private static ValueType[] tempResultValueType = new ValueType[1];

	static Object parseValue( String key, String value ) {
		return parseValue( key, value, null, v -> v, Collections.emptyList() );
	}

	static Object parseValue( String key, String value, ValueType[] resultValueType,
		Function<String, String> resolver, List<ClassLoader> addonClassLoaders )
	{
		if( resultValueType == null )
			resultValueType = tempResultValueType;

		value = value.trim();

		// null, false, true
		switch( value ) {
			case "null":	resultValueType[0] = ValueType.NULL; return null;
			case "false":	resultValueType[0] = ValueType.BOOLEAN; return false;
			case "true":	resultValueType[0] = ValueType.BOOLEAN; return true;
		}

		// check for function "lazy"
		//     Syntax: lazy(uiKey)
		if( value.startsWith( "lazy(" ) && value.endsWith( ")" ) ) {
			resultValueType[0] = ValueType.LAZY;
			String uiKey = value.substring( 5, value.length() - 1 ).trim();
			return (LazyValue) t -> {
				return lazyUIManagerGet( uiKey );
			};
		}

		ValueType valueType = ValueType.UNKNOWN;

		// check whether value type is specified in the value
		if( value.startsWith( "#" ) )
			valueType = ValueType.COLOR;
		else if( value.startsWith( "\"" ) && value.endsWith( "\"" ) ) {
			valueType = ValueType.STRING;
			value = value.substring( 1, value.length() - 1 );
		} else if( value.startsWith( TYPE_PREFIX ) ) {
			int end = value.indexOf( TYPE_PREFIX_END );
			if( end != -1 ) {
				try {
					String typeStr = value.substring( TYPE_PREFIX.length(), end );
					valueType = ValueType.valueOf( typeStr.toUpperCase( Locale.ENGLISH ) );

					// remove type from value
					value = value.substring( end + TYPE_PREFIX_END.length() );
				} catch( IllegalArgumentException ex ) {
					// ignore
				}
			}
		}

		// determine value type from key
		if( valueType == ValueType.UNKNOWN ) {
			if( key.endsWith( "UI" ) )
				valueType = ValueType.STRING;
			else if( key.endsWith( "Color" ) ||
				(key.endsWith( "ground" ) &&
				 (key.endsWith( ".background" ) || key.endsWith( "Background" ) ||
				  key.endsWith( ".foreground" ) || key.endsWith( "Foreground" ))) )
				valueType = ValueType.COLOR;
			else if( key.endsWith( ".border" ) || key.endsWith( "Border" ) )
				valueType = ValueType.BORDER;
			else if( key.endsWith( ".icon" ) || key.endsWith( "Icon" ) )
				valueType = ValueType.ICON;
			else if( key.endsWith( ".margin" ) || key.endsWith( ".padding" ) ||
					 key.endsWith( "Margins" ) || key.endsWith( "Insets" ) )
				valueType = ValueType.INSETS;
			else if( key.endsWith( "Size" ) )
				valueType = ValueType.DIMENSION;
			else if( key.endsWith( "Width" ) || key.endsWith( "Height" ) )
				valueType = ValueType.INTEGER;
			else if( key.endsWith( "Char" ) )
				valueType = ValueType.CHARACTER;
			else if( key.endsWith( "grayFilter" ) )
				valueType = ValueType.GRAYFILTER;
		}

		resultValueType[0] = valueType;

		// parse value
		switch( valueType ) {
			case STRING:		return value;
			case CHARACTER:		return parseCharacter( value );
			case INTEGER:		return parseInteger( value, true );
			case FLOAT:			return parseFloat( value, true );
			case BORDER:		return parseBorder( value, resolver, addonClassLoaders );
			case ICON:			return parseInstance( value, addonClassLoaders );
			case INSETS:		return parseInsets( value );
			case DIMENSION:		return parseDimension( value );
			case COLOR:			return parseColorOrFunction( value, resolver, true );
			case SCALEDINTEGER:	return parseScaledInteger( value );
			case SCALEDFLOAT:	return parseScaledFloat( value );
			case SCALEDINSETS:	return parseScaledInsets( value );
			case SCALEDDIMENSION:return parseScaledDimension( value );
			case INSTANCE:		return parseInstance( value, addonClassLoaders );
			case CLASS:			return parseClass( value, addonClassLoaders );
			case GRAYFILTER:	return parseGrayFilter( value );
			case UNKNOWN:
			default:
				// colors
				Object color = parseColorOrFunction( value, resolver, false );
				if( color != null ) {
					resultValueType[0] = ValueType.COLOR;
					return color;
				}

				// integer
				Integer integer = parseInteger( value, false );
				if( integer != null ) {
					resultValueType[0] = ValueType.INTEGER;
					return integer;
				}

				// float
				Float f = parseFloat( value, false );
				if( f != null ) {
					resultValueType[0] = ValueType.FLOAT;
					return f;
				}

				// string
				resultValueType[0] = ValueType.STRING;
				return value;
		}
	}

	private static Object parseBorder( String value, Function<String, String> resolver, List<ClassLoader> addonClassLoaders ) {
		if( value.indexOf( ',' ) >= 0 ) {
			// top,left,bottom,right[,lineColor[,lineThickness]]
			List<String> parts = split( value, ',' );
			Insets insets = parseInsets( value );
			ColorUIResource lineColor = (parts.size() >= 5)
				? (ColorUIResource) parseColorOrFunction( resolver.apply( parts.get( 4 ) ), resolver, true )
				: null;
			float lineThickness = (parts.size() >= 6) ? parseFloat( parts.get( 5 ), true ) : 1f;

			return (LazyValue) t -> {
				return (lineColor != null)
					? new FlatLineBorder( insets, lineColor, lineThickness )
					: new FlatEmptyBorder( insets );
			};
		} else
			return parseInstance( value, addonClassLoaders );
	}

	private static Object parseInstance( String value, List<ClassLoader> addonClassLoaders ) {
		return (LazyValue) t -> {
			try {
				return findClass( value, addonClassLoaders ).newInstance();
			} catch( InstantiationException | IllegalAccessException | ClassNotFoundException ex ) {
				LoggingFacade.INSTANCE.logSevere( "FlatLaf: Failed to instantiate '" + value + "'.", ex );
				return null;
			}
		};
	}

	private static Object parseClass( String value, List<ClassLoader> addonClassLoaders ) {
		return (LazyValue) t -> {
			try {
				return findClass( value, addonClassLoaders );
			} catch( ClassNotFoundException ex ) {
				LoggingFacade.INSTANCE.logSevere( "FlatLaf: Failed to find class '" + value + "'.", ex );
				return null;
			}
		};
	}

	private static Class<?> findClass( String className, List<ClassLoader> addonClassLoaders )
		throws ClassNotFoundException
	{
		try {
			return Class.forName( className );
		} catch( ClassNotFoundException ex ) {
			// search in addons class loaders
			for( ClassLoader addonClassLoader : addonClassLoaders ) {
				try {
					return addonClassLoader.loadClass( className );
				} catch( ClassNotFoundException ex2 ) {
					// ignore
				}
			}
			throw ex;
		}
	}

	private static Insets parseInsets( String value ) {
		List<String> numbers = split( value, ',' );
		try {
			return new InsetsUIResource(
				Integer.parseInt( numbers.get( 0 ) ),
				Integer.parseInt( numbers.get( 1 ) ),
				Integer.parseInt( numbers.get( 2 ) ),
				Integer.parseInt( numbers.get( 3 ) ) );
		} catch( NumberFormatException ex ) {
			throw new IllegalArgumentException( "invalid insets '" + value + "'" );
		}
	}

	private static Dimension parseDimension( String value ) {
		List<String> numbers = split( value, ',' );
		try {
			return new DimensionUIResource(
				Integer.parseInt( numbers.get( 0 ) ),
				Integer.parseInt( numbers.get( 1 ) ) );
		} catch( NumberFormatException ex ) {
			throw new IllegalArgumentException( "invalid size '" + value + "'" );
		}
	}

	private static Object parseColorOrFunction( String value, Function<String, String> resolver, boolean reportError ) {
		if( value.endsWith( ")" ) )
			return parseColorFunctions( value, resolver, reportError );

		return parseColor( value, reportError );
	}

	static ColorUIResource parseColor( String value ) {
		return parseColor( value, false );
	}

	private static ColorUIResource parseColor( String value, boolean reportError ) {
		try {
			int rgba = parseColorRGBA( value );
			return ((rgba & 0xff000000) == 0xff000000)
				? new ColorUIResource( rgba )
				: new ColorUIResource( new Color( rgba, true ) );
		} catch( IllegalArgumentException ex ) {
			if( reportError )
				throw new IllegalArgumentException( "invalid color '" + value + "'" );

			// not a color --> ignore
		}
		return null;
	}

	/**
	 * Parses a hex color in  {@code #RGB}, {@code #RGBA}, {@code #RRGGBB} or {@code #RRGGBBAA}
	 * format and returns it as {@code rgba} integer suitable for {@link java.awt.Color},
	 * which includes alpha component in bits 24-31.
	 *
	 * @throws IllegalArgumentException
	 */
	static int parseColorRGBA( String value ) {
		int len = value.length();
		if( (len != 4 && len != 5 && len != 7 && len != 9) || value.charAt( 0 ) != '#' )
			throw new IllegalArgumentException();

		// parse hex
		int n = 0;
		for( int i = 1; i < len; i++ ) {
			char ch = value.charAt( i );

			int digit;
			if( ch >= '0' && ch <= '9' )
				digit = ch - '0';
			else if( ch >= 'a' && ch <= 'f' )
				digit = ch - 'a' + 10;
			else if( ch >= 'A' && ch <= 'F' )
				digit = ch - 'A' + 10;
			else
				throw new IllegalArgumentException();

			n = (n << 4) | digit;
		}

		if( len <= 5 ) {
			// double nibbles
			int n1 = n & 0xf000;
			int n2 = n & 0xf00;
			int n3 = n & 0xf0;
			int n4 = n & 0xf;
			n = (n1 << 16) | (n1 << 12) | (n2 << 12) | (n2 << 8) | (n3 << 8) | (n3 << 4) | (n4 << 4) | n4;
		}

		return (len == 4 || len == 7)
			? (0xff000000 | n) // set alpha to 255
			: (((n >> 8) & 0xffffff) | ((n & 0xff) << 24)); // move alpha from lowest to highest byte
	}

	private static Object parseColorFunctions( String value, Function<String, String> resolver, boolean reportError ) {
		int paramsStart = value.indexOf( '(' );
		if( paramsStart < 0 ) {
			if( reportError )
				throw new IllegalArgumentException( "missing opening parenthesis in function '" + value + "'" );
			return null;
		}

		String function = value.substring( 0, paramsStart ).trim();
		List<String> params = splitFunctionParams( value.substring( paramsStart + 1, value.length() - 1 ), ',' );
		if( params.isEmpty() )
			throw new IllegalArgumentException( "missing parameters in function '" + value + "'" );

		if( parseColorDepth > 100 )
			throw new IllegalArgumentException( "endless recursion in color function '" + value + "'" );

		parseColorDepth++;
		try {
			switch( function ) {
				case "rgb":			return parseColorRgbOrRgba( false, params, resolver, reportError );
				case "rgba":		return parseColorRgbOrRgba( true, params, resolver, reportError );
				case "hsl":			return parseColorHslOrHsla( false, params );
				case "hsla":		return parseColorHslOrHsla( true, params );
				case "lighten":		return parseColorHSLIncreaseDecrease( 2, true, params, resolver, reportError );
				case "darken":		return parseColorHSLIncreaseDecrease( 2, false, params, resolver, reportError );
				case "saturate":	return parseColorHSLIncreaseDecrease( 1, true, params, resolver, reportError );
				case "desaturate":	return parseColorHSLIncreaseDecrease( 1, false, params, resolver, reportError );
				case "fadein":		return parseColorHSLIncreaseDecrease( 3, true, params, resolver, reportError );
				case "fadeout":		return parseColorHSLIncreaseDecrease( 3, false, params, resolver, reportError );
				case "fade":		return parseColorFade( params, resolver, reportError );
				case "spin":		return parseColorSpin( params, resolver, reportError );
			}
		} finally {
			parseColorDepth--;
		}

		throw new IllegalArgumentException( "unknown color function '" + value + "'" );
	}

	/**
	 * Syntax: rgb(red,green,blue) or rgba(red,green,blue,alpha)
	 *   - red:   an integer 0-255 or a percentage 0-100%
	 *   - green: an integer 0-255 or a percentage 0-100%
	 *   - blue:  an integer 0-255 or a percentage 0-100%
	 *   - alpha: an integer 0-255 or a percentage 0-100%
	 */
	private static ColorUIResource parseColorRgbOrRgba( boolean hasAlpha, List<String> params,
		Function<String, String> resolver, boolean reportError )
	{
		if( hasAlpha && params.size() == 2 ) {
			// syntax rgba(color,alpha), which allows adding alpha to any color
			// NOTE: this syntax is deprecated
			//       use fade(color,alpha) instead
			String colorStr = params.get( 0 );
			int alpha = parseInteger( params.get( 1 ), 0, 255, true );

			ColorUIResource color = (ColorUIResource) parseColorOrFunction( resolver.apply( colorStr ), resolver, reportError );
			return new ColorUIResource( new Color( ((alpha & 0xff) << 24) | (color.getRGB() & 0xffffff), true ) );
		}

		int red = parseInteger( params.get( 0 ), 0, 255, true );
		int green = parseInteger( params.get( 1 ), 0, 255, true );
		int blue = parseInteger( params.get( 2 ), 0, 255, true );
		int alpha = hasAlpha ? parseInteger( params.get( 3 ), 0, 255, true ) : 255;

		return hasAlpha
			? new ColorUIResource( new Color( red, green, blue, alpha ) )
			: new ColorUIResource( red, green, blue );
	}

	/**
	 * Syntax: hsl(hue,saturation,lightness) or hsla(hue,saturation,lightness,alpha)
	 *   - hue: an integer 0-360 representing degrees
	 *   - saturation: a percentage 0-100%
	 *   - lightness: a percentage 0-100%
	 *   - alpha: a percentage 0-100%
	 */
	private static ColorUIResource parseColorHslOrHsla( boolean hasAlpha, List<String> params ) {
		int hue = parseInteger( params.get( 0 ), 0, 360, false );
		int saturation = parsePercentage( params.get( 1 ) );
		int lightness = parsePercentage( params.get( 2 ) );
		int alpha = hasAlpha ? parsePercentage( params.get( 3 ) ) : 100;

		float[] hsl = new float[] { hue, saturation, lightness };
		return new ColorUIResource( HSLColor.toRGB( hsl, alpha / 100f ) );
	}

	/**
	 * Syntax: lighten(color,amount[,options]) or darken(color,amount[,options]) or
	 *         saturate(color,amount[,options]) or desaturate(color,amount[,options]) or
	 *         fadein(color,amount[,options]) or fadeout(color,amount[,options])
	 *   - color: a color (e.g. #f00) or a color function
	 *   - amount: percentage 0-100%
	 *   - options: [relative] [autoInverse] [noAutoInverse] [lazy] [derived]
	 */
	private static Object parseColorHSLIncreaseDecrease( int hslIndex, boolean increase,
		List<String> params, Function<String, String> resolver, boolean reportError )
	{
		String colorStr = params.get( 0 );
		int amount = parsePercentage( params.get( 1 ) );
		boolean relative = false;
		boolean autoInverse = false;
		boolean lazy = false;
		boolean derived = false;

		if( params.size() > 2 ) {
			String options = params.get( 2 );
			relative = options.contains( "relative" );
			autoInverse = options.contains( "autoInverse" );
			lazy = options.contains( "lazy" );
			derived = options.contains( "derived" );

			// use autoInverse by default for derived colors, except if noAutoInverse is set
			if( derived && !options.contains( "noAutoInverse" ) )
				autoInverse = true;
		}

		// create function
		ColorFunction function = new ColorFunctions.HSLIncreaseDecrease(
			hslIndex, increase, amount, relative, autoInverse );

		if( lazy ) {
			return (LazyValue) t -> {
				Object color = lazyUIManagerGet( colorStr );
				return (color instanceof Color)
					? new ColorUIResource( ColorFunctions.applyFunctions( (Color) color, function ) )
					: null;
			};
		}

		// parse base color, apply function and create derived color
		return parseFunctionBaseColor( colorStr, function, derived, resolver, reportError );
	}

	/**
	 * Syntax: fade(color,amount[,options])
	 *   - color: a color (e.g. #f00) or a color function
	 *   - amount: percentage 0-100%
	 *   - options: [derived]
	 */
	private static Object parseColorFade( List<String> params, Function<String, String> resolver, boolean reportError ) {
		String colorStr = params.get( 0 );
		int amount = parsePercentage( params.get( 1 ) );
		boolean derived = false;

		if( params.size() > 2 ) {
			String options = params.get( 2 );
			derived = options.contains( "derived" );
		}

		// create function
		ColorFunction function = new ColorFunctions.Fade( amount );

		// parse base color, apply function and create derived color
		return parseFunctionBaseColor( colorStr, function, derived, resolver, reportError );
	}

	/**
	 * Syntax: spin(color,angle[,options])
	 *   - color: a color (e.g. #f00) or a color function
	 *   - angle: number of degrees to rotate
	 *   - options: [derived]
	 */
	private static Object parseColorSpin( List<String> params, Function<String, String> resolver, boolean reportError ) {
		String colorStr = params.get( 0 );
		int amount = parseInteger( params.get( 1 ), true );
		boolean derived = false;

		if( params.size() > 2 ) {
			String options = params.get( 2 );
			derived = options.contains( "derived" );
		}

		// create function
		ColorFunction function = new ColorFunctions.HSLIncreaseDecrease( 0, true, amount, false, false );

		// parse base color, apply function and create derived color
		return parseFunctionBaseColor( colorStr, function, derived, resolver, reportError );
	}

	private static Object parseFunctionBaseColor( String colorStr, ColorFunction function,
		boolean derived, Function<String, String> resolver, boolean reportError )
	{
		// parse base color
		String resolvedColorStr = resolver.apply( colorStr );
		ColorUIResource baseColor = (ColorUIResource) parseColorOrFunction( resolvedColorStr, resolver, reportError );
		if( baseColor == null )
			return null;

		// apply this function to base color
		Color newColor = ColorFunctions.applyFunctions( baseColor, function );

		if( derived ) {
			ColorFunction[] functions;
			if( baseColor instanceof DerivedColor && resolvedColorStr == colorStr ) {
				// if the base color is also derived, join the color functions
				// but only if base color function is specified directly in this function
				ColorFunction[] baseFunctions = ((DerivedColor)baseColor).getFunctions();
				functions = new ColorFunction[baseFunctions.length + 1];
				System.arraycopy( baseFunctions, 0, functions, 0, baseFunctions.length );
				functions[baseFunctions.length] = function;
			} else
				functions = new ColorFunction[] { function };

			return new DerivedColor( newColor, functions );
		}

		return new ColorUIResource( newColor );
	}

	private static int parsePercentage( String value ) {
		if( !value.endsWith( "%" ) )
			throw new NumberFormatException( "invalid percentage '" + value + "'" );

		int val;
		try {
			val = Integer.parseInt( value.substring( 0, value.length() - 1 ) );
		} catch( NumberFormatException ex ) {
			throw new NumberFormatException( "invalid percentage '" + value + "'" );
		}

		if( val < 0 || val > 100 )
			throw new IllegalArgumentException( "percentage out of range (0-100%) '" + value + "'" );
		return val;
	}

	private static Character parseCharacter( String value ) {
		if( value.length() != 1 )
			throw new IllegalArgumentException( "invalid character '" + value + "'" );
		return value.charAt( 0 );
	}

	private static Integer parseInteger( String value, int min, int max, boolean allowPercentage ) {
		if( allowPercentage && value.endsWith( "%" ) ) {
			int percent = parsePercentage( value );
			return (max * percent) / 100;
		}

		Integer integer = parseInteger( value, true );
		if( integer.intValue() < min || integer.intValue() > max )
			throw new NumberFormatException( "integer '" + value + "' out of range (" + min + '-' + max + ')' );
		return integer;
	}

	private static Integer parseInteger( String value, boolean reportError ) {
		try {
			return Integer.parseInt( value );
		} catch( NumberFormatException ex ) {
			if( reportError )
				throw new NumberFormatException( "invalid integer '" + value + "'" );
		}
		return null;
	}

	private static Float parseFloat( String value, boolean reportError ) {
		try {
			return Float.parseFloat( value );
		} catch( NumberFormatException ex ) {
			if( reportError )
				throw new NumberFormatException( "invalid float '" + value + "'" );
		}
		return null;
	}

	private static ActiveValue parseScaledInteger( String value ) {
		int val = parseInteger( value, true );
		return (ActiveValue) t -> {
			return UIScale.scale( val );
		};
	}

	private static ActiveValue parseScaledFloat( String value ) {
		float val = parseFloat( value, true );
		return (ActiveValue) t -> {
			return UIScale.scale( val );
		};
	}

	private static ActiveValue parseScaledInsets( String value ) {
		Insets insets = parseInsets( value );
		return (ActiveValue) t -> {
			return UIScale.scale( insets );
		};
	}

	private static ActiveValue parseScaledDimension( String value ) {
		Dimension dimension = parseDimension( value );
		return (ActiveValue) t -> {
			return UIScale.scale( dimension );
		};
	}

	private static Object parseGrayFilter( String value ) {
		List<String> numbers = split( value, ',' );
		try {
			int brightness = Integer.parseInt( numbers.get( 0 ) );
			int contrast = Integer.parseInt( numbers.get( 1 ) );
			int alpha = Integer.parseInt( numbers.get( 2 ) );

			return (LazyValue) t -> {
				return new GrayFilter( brightness, contrast, alpha );
			};
		} catch( NumberFormatException ex ) {
			throw new IllegalArgumentException( "invalid gray filter '" + value + "'" );
		}
	}

	/**
	 * Split string and trim parts.
	 */
	private static List<String> split( String str, char delim ) {
		List<String> result = StringUtils.split( str, delim );

		// trim strings
		int size = result.size();
		for( int i = 0; i < size; i++ )
			result.set( i, result.get( i ).trim() );

		return result;
	}

	/**
	 * Splits function parameters and allows using functions as parameters.
	 * In other words: Delimiters surrounded by '(' and ')' are ignored.
	 */
	private static List<String> splitFunctionParams( String str, char delim ) {
		ArrayList<String> strs = new ArrayList<>();
		int nestLevel = 0;
		int start = 0;
		int strlen = str.length();
		for( int i = 0; i < strlen; i++ ) {
			char ch = str.charAt( i );
			if( ch == '(' )
				nestLevel++;
			else if( ch == ')' )
				nestLevel--;
			else if( nestLevel == 0 && ch == delim ) {
				strs.add( str.substring( start, i ).trim() );
				start = i + 1;
			}
		}
		strs.add( str.substring( start ).trim() );

		return strs;
	}

	/**
	 * For use in LazyValue to get value for given key from UIManager and report error
	 * if not found. If key is prefixed by '?', then no error is reported.
	 */
	private static Object lazyUIManagerGet( String uiKey ) {
		boolean optional = false;
		if( uiKey.startsWith( OPTIONAL_PREFIX ) ) {
			uiKey = uiKey.substring( OPTIONAL_PREFIX.length() );
			optional = true;
		}

		Object value = UIManager.get( uiKey );
		if( value == null && !optional )
			LoggingFacade.INSTANCE.logSevere( "FlatLaf: '" + uiKey + "' not found in UI defaults.", null );
		return value;
	}
}
