LogProperties.java

package io.jstach.rainbowgum;

import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.io.UncheckedIOException;
import java.lang.System.Logger.Level;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.BitSet;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.jspecify.annotations.Nullable;

import io.jstach.rainbowgum.LogAppender.AppenderFlag;
import io.jstach.rainbowgum.LogConfig.ChangePublisher.ChangeType;
import io.jstach.rainbowgum.LogProperties.Builder.AbstractLogProperties;
import io.jstach.rainbowgum.LogProperties.MutableLogProperties;
import io.jstach.rainbowgum.annotation.CaseChanging;
import io.jstach.rainbowgum.annotation.LogConfigurable;

/**
 * Provides String based properties like {@link System#getProperty(String)} for default
 * configuration of logging levels and output. Since Rainbow Gum is configuration agnostic
 * LogProperties is a simple string key and string value interface to allow almost any
 * configuration implementation. The only required method to implement is
 * {@link #valueOrNull(String)} and thus LogProperties is a functional like interface but
 * using {@link LogProperties#builder()} is preferred so that a
 * {@linkplain #description(String) description} can be added.
 * <p>
 * If a custom {@link LogProperties} is not configured the default RainbowGum uses System
 * properties.
 * <p>
 * The built-in properties to configure RainbowGum are modeled after <a href=
 * "https://docs.spring.io/spring-boot/docs/3.1.0/reference/html/features.html#features.logging">
 * Spring Boot logging </a>
 * <p>
 * LogProperties should generally be accessed with {@link #forKey(String)} (see
 * {@link LogProperty}) which will allow safe <em>programmatic</em> data conversion of the
 * flat key values and useful error messages to users if the property cannot be mapped or
 * is missing. For more <em>declarative</em> injection of properties see
 * {@link LogConfigurable} which will generate builders that can use LogProperties.
 * <p>
 * Rainbow Gum treats {@value #SEP} in the keys as special separator analogous to
 * JavaScript/JSON and various other configuration systems. Furthermore to work with a
 * wide set of configuration systems once a property key has a value it is
 * <strong>RECOMMENDED</strong> that there should not be other property key value with a
 * key that is a prefix of the previously mentioned key. For example allowing two
 * properties <code>logging.example=stuff</code>, <code>logging.example.a=A</code> is not
 * recommended as <code>logging.example</code> is defined as a leaf. The exception to this
 * rule is logging levels mapped to logger names for consistency with Spring Boot.
 * <p>
 * Rainbow Gum out of the the box supports parsing two formats into properties:
 * {@link Properties} text, and {@link URI#getRawQuery()} percent encoded query
 * parameters. The two different formats have different behavior for
 * {@link #listOrNull(String)} and {@link #mapOrNull(String)}. If a custom format of
 * LogProperties is created those two methods may need to be extended.
 * <p>
 * A convention used through out Rainbow Gum is to have the property names as constants as
 * it makes documentation easier. These constants name are suffixed with
 * <code>_PROPERTY</code> or <code>_PREFIX</code> the former being a fully qualified key
 * that maybe parameterized and the latter being a partial key ending with
 * "<code>.</code>". The fully qualified keys are like leaves on a tree.
 * <p>
 * Property keys can be parameterized by using
 * {@link LogProperties#interpolateKey(String, Map)} and a general pattern is
 * <code>logging.component.{name}.prop</code> where {@value #NAME} is a parameter. Many
 * property constants use this to allow multiple configuration of components by name.
 *
 * <table class="table">
 * <caption><strong>Built-in Property Patterns</strong></caption>
 * <tr>
 * <th>Property Pattern</th>
 * <th>Description</th>
 * </tr>
 * <tr>
 * <td>{@value #LEVEL_PREFIX} + {@value #SEP} + logger = LEVEL</td>
 * <td>Sets the level for the logger. If logger name is missing then it is considered the
 * root logger. {@link Level#INFO} is the default level for the root logger.</td>
 * </tr>
 * <tr>
 * <td>{@value #CHANGE_PREFIX} + {@value #SEP} + logger = boolean |
 * List&lt;{@link ChangeType}&gt;</td>
 * <td>Allows runtime changing of levels for the logger. By design RainbowGum does not
 * allow loggers to change levels once initialized. This configuration will allow the
 * level to be changed for the logger and its children and by default is false as the
 * built-in {@link LogProperties} is generally static (system properties).</td>
 * </tr>
 * <tr>
 * <td>{@value #ROUTES_PROPERTY} = <code>List&lt;String&gt;</code></td>
 * <td>A comma separated list of routes that should be enabled. If not set
 * {@value LogRouter.Router#DEFAULT_ROUTER_NAME } is the default.</td>
 * </tr>
 * <tr>
 * <td>{@value #ROUTE_APPENDERS_PROPERTY} = <code>List&lt;String&gt;</code></td>
 * <td>A comma separated list of appenders associated with the route. If not set
 * {@value #APPENDERS_PROPERTY} is used.</td>
 * </tr>
 * <tr>
 * <td>{@value #ROUTE_PUBLISHER_PROPERTY} = <code>URI</code></td>
 * <td>Looks up a publisher by URI scheme using the {@link LogPublisherRegistry}. The
 * publisher is then configured by the other properties starting with
 * {@value #PUBLISHER_PREFIX}.</td>
 * </tr>
 * <tr>
 * <td>{@value #PUBLISHER_PREFIX} + <code>scheme</code> + <code>.</code> +
 * <code>propertyName</code></td>
 * <td>Configures the route's publisher of the type selected by the URI scheme, for
 * example <code>logging.route.default.publisher.async.bufferSize</code>. The scheme is
 * the one written in the URI, so <code>async</code> settings apply to whichever publisher
 * is registered as <code>async</code>.</td>
 * </tr>
 * <tr>
 * <td>{@value #APPENDERS_PROPERTY} = <code>List&lt;String&gt;</code></td>
 * <td>A comma separated list of appenders that should be enabled. If not set
 * {@value LogAppender#CONSOLE_APPENDER_NAME} is the default.</td>
 * </tr>
 * <tr>
 * <td>{@value #APPENDER_OUTPUT_PROPERTY } = <code>URI</code></td>
 * <td>Looks up an output by URI scheme using the {@link LogOutputRegistry}. The output is
 * then configured by the other properties starting with {@value #OUTPUT_PREFIX}.</td>
 * </tr>
 * <tr>
 * <td>{@value #APPENDER_ENCODER_PROPERTY } = <code>URI</code></td>
 * <td>Looks up an encoder by URI scheme using the {@link LogEncoderRegistry}. The encoder
 * is then configured by the other properties starting with {@value #ENCODER_PREFIX}.</td>
 * </tr>
 * <tr>
 * <td>{@value #OUTPUT_PREFIX} + <code>scheme</code> + <code>.</code> +
 * <code>propertyName</code></td>
 * <td>Configures the appender's output of the type selected by the URI scheme, for
 * example <code>logging.appender.file.output.file.bufferSize</code>. An output built
 * without an appender uses its own name in place of the appender's.</td>
 * </tr>
 * <tr>
 * <td>{@value #ENCODER_PREFIX} + <code>scheme</code> + <code>.</code> +
 * <code>propertyName</code></td>
 * <td>Configures the appender's encoder of the type selected by the URI scheme, for
 * example <code>logging.appender.console.encoder.json5.keyValues</code>. Each type has
 * its own keys, so switching an appender's encoder leaves the other type's settings
 * unread rather than misread. An encoder built without an appender uses its own name in
 * place of the appender's.</td>
 * </tr>
 * <tr>
 * <td>{@value #FILE_PROPERTY} = URI</td>
 * <td>A URI or file path to log to a file. This is for Spring Boot compatibility.</td>
 * </tr>
 * </table>
 *
 * @see LogConfigurable
 */
public interface LogProperties {

	/**
	 * properties separator.
	 */
	static final String SEP = ".";

	/**
	 * Logging properties prefix.
	 */
	static final String ROOT_PREFIX = "logging" + SEP;

	/**
	 * Controls direct fail-safe bootstrap diagnostics. {@code OFF} (also {@code false})
	 * is the default; {@code ERROR} dumps on build failure, while {@code INFO} also dumps
	 * after successful config builds. {@code ALL} (also {@code true}) additionally prints
	 * a {@link LogReporter} report after {@link RainbowGum#start()} starts the logging
	 * components. Read from system properties before properties providers run, so it
	 * remains available when loading configuration fails. The system property overrides
	 * {@link LogConfig.Builder#debug(LogConfig.DebugModeType)}.
	 */
	static final String DEBUG_PROPERTY = ROOT_PREFIX + "debug";

	/**
	 * Logging level properties prefix. The value should be the name of a
	 * {@linkplain java.lang.System.Logger.Level level}.
	 */
	static final String LEVEL_PREFIX = ROOT_PREFIX + "level";

	/**
	 * Enabled level groups. The value should be a list of group names used on
	 * {@link #GROUP_PROPERTY}. This is to enable <a href=
	 * "https://docs.spring.io/spring-boot/3.3.2/reference/features/logging.html#features.logging.log-groups">
	 * Spring Boot like Log Groups </a>. <em>Note that unlike Spring Boot groups are not
	 * enabled unless this property contains the group.</em>
	 */
	static final String GROUPS_PROPERTY = ROOT_PREFIX + "groups";

	/**
	 * Group property with list of logger names. The level of the group is set with
	 * <code>{@value #LEVEL_PREFIX} + .{name} = Level</code> or on the router itself with
	 * <code>{@value #ROUTE_LEVEL_PREFIX} + .{name} = Level</code>. This is largely
	 * compatible with <a href=
	 * "https://docs.spring.io/spring-boot/3.3.2/reference/features/logging.html#features.logging.log-groups">
	 * Spring Boot Log Groups </a>.
	 * @see LevelResolver#parseLevel(String)
	 */
	static final String GROUP_PROPERTY = ROOT_PREFIX + "group.{name}";

	/**
	 * Logging change configuration allowed property.
	 */
	static final String GLOBAL_CHANGE_PROPERTY = ROOT_PREFIX + "global.change";

	/**
	 * The level threshold of events captured before Rainbow Gum is fully bound. Before
	 * Rainbow Gum is bound events are queued. By default events that are below
	 * <code>INFO</code> are not queued.
	 */
	static final String GLOBAL_QUEUE_LEVEL_PROPERTY = ROOT_PREFIX + "global.queue.level";

	/**
	 * The level threshold of queued events reported to standard error before Rainbow Gum
	 * is bound. Defaults to <code>ERROR</code>.
	 */
	static final String GLOBAL_QUEUE_ERROR_PROPERTY = ROOT_PREFIX + "global.queue.error";

	/**
	 * Will globally turn of any ANSI escape output as well disable extensions that do
	 * things for ANSI escape like JANSI. If not set this is derived automatically from
	 * {@link io.jstach.rainbowgum.format.AnsiSupport#isAnsiSupported()} which checks
	 * whether the process is attached to an ANSI capable terminal.
	 */
	static final String GLOBAL_ANSI_DISABLE_PROPERTY = ROOT_PREFIX + "global.ansi.disable";

	/**
	 * If true guarantees no appender will use {@code synchronized} for its locking,
	 * regardless of an explicitly requested
	 * {@link LogAppender.AppenderType#SYNCHRONIZED_THREAD_LOCAL_BUFFER}: an explicit
	 * request for that type is downgraded to
	 * {@link LogAppender.AppenderType#LOCK_THREAD_LOCAL_BUFFER} instead of being honored.
	 * Intended for deployments that want a hard guarantee independent of per-appender
	 * configuration - e.g. still running JDK versions before
	 * <a href="https://openjdk.org/jeps/491">JEP 491</a> (JDK 24), where
	 * {@code synchronized} pins the carrier platform thread when called from a virtual
	 * thread, and that would rather not rely on per-appender configuration to avoid it.
	 */
	static final String GLOBAL_APPENDER_REENTRANT_LOCK_PROPERTY = ROOT_PREFIX + "global.appender.reentrantLock";

	/**
	 * A global, process-wide guarantee of no {@link ThreadLocal} anywhere in the logging
	 * path, for deployments that want that guaranteed independent of
	 * per-appender/per-module configuration. When active:
	 * <ul>
	 * <li>Any appender that would otherwise use
	 * {@link LogAppender.AppenderType#LOCK_THREAD_LOCAL_BUFFER} or
	 * {@link LogAppender.AppenderType#SYNCHRONIZED_THREAD_LOCAL_BUFFER} (the default,
	 * plus its {@code synchronized}-locking sibling - both {@link ThreadLocal}-backed) is
	 * downgraded to {@link LogAppender.AppenderType#LOCK_NEW_BUFFER} instead - an
	 * explicit request for {@link LogAppender.AppenderType#REUSE_BUFFER} (already
	 * {@link ThreadLocal}-free) is left as-is rather than also forced to
	 * {@code LOCK_NEW_BUFFER}.</li>
	 * <li>{@code rainbowgum-slf4j}'s MDC support is disabled, the same as an explicit
	 * {@code logging.mdc.type=NOOP} - see {@code RainbowGumSLF4JServiceProvider}.</li>
	 * </ul>
	 * @apiNote unlike {@link #GLOBAL_APPENDER_REENTRANT_LOCK_PROPERTY} this reaches
	 * beyond {@code core} into {@code rainbowgum-slf4j} - both independently read this
	 * same property key rather than one module exposing a shared static flag the other
	 * consumes, since there is no other cross-module wiring between them today.
	 */
	static final String GLOBAL_THREADLOCAL_DISABLED_PROPERTY = ROOT_PREFIX + "global.threadlocalDisabled";

	/**
	 * Opts every appender/encoder that has not been given an explicit choice into
	 * whatever this release currently believes is the best buffering/locking strategy for
	 * the platform the process is actually running on, instead of the fixed defaults
	 * ({@link LogAppender.AppenderType#LOCK_THREAD_LOCAL_BUFFER}, a
	 * {@link java.nio.charset.CharsetEncoder}-based encode) used otherwise. An explicit
	 * choice - programmatic ({@code appenderType(...)}, {@code useGetBytes(...)}) or via
	 * a more specific property ({@link LogAppender#APPENDER_TYPE_PROPERTY}) - always wins
	 * over this property; it only ever fills in what would otherwise be unset.
	 * <p>
	 * <strong>What "best" resolves to today, and why that is not a contract:</strong>
	 * currently this only has an effect under GraalVM native-image (detected the same way
	 * {@link LogAppender.AppenderType#AUTO_DETECT} is - see its javadoc), where it sets
	 * the default appender type to {@code AUTO_DETECT} (this benchmark's own measurements
	 * found {@code SYNCHRONIZED_THREAD_LOCAL_BUFFER} a real, repeatable win over the
	 * plain-JVM default there) and defaults text encoders to
	 * {@code String.getBytes(Charset)}-based encoding over the default
	 * {@code CharsetEncoder}-based one. On a plain JVM this property currently changes
	 * nothing at all - the measurements that motivated the native-image behavior above
	 * pointed the other way on HotSpot. None of this is a promise: as this project
	 * benchmarks more platforms, JDK versions, and encode strategies, what "optimize"
	 * resolves to on a given platform - including whether it does anything on a plain JVM
	 * at all - can and will change between releases without notice. Pin an explicit
	 * appender type/encoder strategy instead if a deployment needs a specific one to
	 * always be used.
	 */
	static final String GLOBAL_OPTIMIZE_PROPERTY = ROOT_PREFIX + "global.optimize";

	/**
	 * Key values namespace, for properties that configure where an event's key values
	 * come from.
	 */
	static final String KEY_VALUES_PREFIX = ROOT_PREFIX + "keyvalues" + SEP;

	/**
	 * A comma separated list of key value sources (the lowercase names of
	 * {@link LogEventFactory.KeyValuesContributor.Source.Standard}, for example
	 * <code>defaults,slf4j</code>) whose contributors are left out of every event's key
	 * values. A logging facade's own context store is still used for events logged
	 * through that facade, and {@link LogEventFactory.KeyValuesContributor#clear()} still
	 * clears it according to
	 * {@link LogEventFactory.KeyValuesContributor.Source.Standard#clearWhenDisabled()}.
	 */
	static final String KEY_VALUES_DISABLED_PROPERTY = KEY_VALUES_PREFIX + "disabled";

	/**
	 * Maximum number of alerts {@link LogAlerts} holds in its ring buffer. Read once,
	 * from whatever {@link LogProperties} {@link LogConfig.Builder#build()} has already
	 * resolved by the time it constructs {@link LogAlerts} - before any
	 * {@link io.jstach.rainbowgum.spi.RainbowGumServiceProvider.Configurator} runs.
	 * @see LogAlerts#DEFAULT_CAPACITY
	 */
	static final String ALERTS_CAPACITY_PROPERTY = ROOT_PREFIX + "alerts.capacity";

	/**
	 * What {@link LogAlerts} does, once on {@link LogLifecycle#start(LogConfig) start},
	 * if it has recorded at least one error alert and still has zero
	 * {@link LogAlerts.Listener}s registered - see
	 * {@link LogAlerts.UnobservedErrorsAction} for the three choices and
	 * {@link LogAlerts.UnobservedErrorsAction#DUMP} for the default.
	 */
	static final String ALERTS_UNOBSERVED_ERRORS_ACTION_PROPERTY = ROOT_PREFIX + "alerts.unobservedErrorsAction";

	/**
	 * Alert level at which starting Rainbow Gum fails, like treating compiler warnings as
	 * errors: see {@link LogAlerts.FailLevel}. Unlike
	 * {@value #ALERTS_UNOBSERVED_ERRORS_ACTION_PROPERTY} it fails whether or not a
	 * {@link LogAlerts.Listener} is registered.
	 */
	static final String ALERTS_FAIL_PROPERTY = ROOT_PREFIX + "alerts.fail";

	/**
	 * Alert level for property keys that were set but not read while starting: see
	 * {@link LogAlerts.UnusedKeyLevel}.
	 */
	static final String ALERTS_UNUSED_KEY_PROPERTY = ROOT_PREFIX + "alerts.unusedKey";

	/**
	 * Logging change properties prefix.
	 */
	static final String CHANGE_PREFIX = ROOT_PREFIX + "change";

	/**
	 * Logging caller info properties prefix. Independent of {@link #CHANGE_PREFIX} -
	 * resolved through its own logger-name hierarchy walk, so a value here can never be
	 * silently overridden by a more specific {@link #CHANGE_PREFIX} entry or vice versa.
	 */
	static final String CALLER_PREFIX = ROOT_PREFIX + "caller";

	/**
	 * Logging file property for default single file appending. The value should be a URI.
	 */
	static final String FILE_PROPERTY = ROOT_PREFIX + "file.name";

	/**
	 * A common key parameter is called name.
	 */
	static final String NAME = "name";

	/**
	 * For properties keys that are parameterized with {@value #NAME} the name often used
	 * if not specified is: {@value #DEFAULT_NAME}.
	 */
	static final String DEFAULT_NAME = "default";

	/**
	 * Enabled Logging appenders. The value should be a list of names. This is equivalent
	 * to {@value #ROUTE_APPENDERS_PROPERTY} with name "default".
	 */
	static final String APPENDERS_PROPERTY = ROOT_PREFIX + "appenders";

	/**
	 * Logging appender prefix for configuration.
	 */
	static final String APPENDER_PREFIX = ROOT_PREFIX + "appender.{" + NAME + "}.";

	/**
	 * Prefix of the properties configuring an appender's output, followed by the output's
	 * URI scheme, for example <code>logging.appender.{name}.output.file.</code>. Also
	 * used for an output built without an appender.
	 */
	static final String OUTPUT_PREFIX = APPENDER_PREFIX + "output.";

	/**
	 * Prefix of the properties configuring an appender's encoder, followed by the
	 * encoder's URI scheme, for example
	 * <code>logging.appender.{name}.encoder.json5.</code>. Also used for an encoder built
	 * without an appender.
	 */
	static final String ENCODER_PREFIX = APPENDER_PREFIX + "encoder.";

	/**
	 * Output appender property.
	 */
	static final String APPENDER_OUTPUT_PROPERTY = OUTPUT_PREFIX + "uri";

	/**
	 * Encoder appender property.
	 */
	static final String APPENDER_ENCODER_PROPERTY = ENCODER_PREFIX + "uri";

	/**
	 * Appender flags. A list of flags (usually comma separated).
	 * @see AppenderFlag
	 */
	static final String APPENDER_FLAGS_PROPERTY = LogProperties.APPENDER_PREFIX + "flags";

	/**
	 * Appender type (buffer/locking strategy). Unlike {@value #APPENDER_FLAGS_PROPERTY}
	 * this is a single value, not a list.
	 * @see LogAppender.AppenderType
	 */
	static final String APPENDER_TYPE_PROPERTY = LogProperties.APPENDER_PREFIX + "type";

	/**
	 * Routes enabled with names comma separated.
	 */
	static final String ROUTES_PROPERTY = ROOT_PREFIX + "routes";

	/**
	 * Route prefix for configuration.
	 */
	static final String ROUTE_PREFIX = ROOT_PREFIX + "route.{" + NAME + "}.";

	/**
	 * Prefix of the properties configuring a route's publisher, followed by the URI
	 * scheme of {@value #ROUTE_PUBLISHER_PROPERTY}, for example
	 * <code>logging.route.{name}.publisher.async.</code>. Also used for a publisher built
	 * without a route.
	 */
	static final String PUBLISHER_PREFIX = ROUTE_PREFIX + "publisher.";

	/**
	 * Logging publisher URI property.
	 */
	static final String ROUTE_PUBLISHER_PROPERTY = PUBLISHER_PREFIX + "uri";

	/**
	 * Appenders associated with a route comma separated.
	 */
	static final String ROUTE_APPENDERS_PROPERTY = ROUTE_PREFIX + "appenders";

	/**
	 * Logging level properties prefix. The value should be the name of a
	 * {@linkplain java.lang.System.Logger.Level level}.
	 */
	static final String ROUTE_LEVEL_PREFIX = ROUTE_PREFIX + "level";

	/**
	 * Route flags.
	 */
	static final String ROUTE_FLAGS_PROPERTY = ROUTE_PREFIX + "flags";

	/**
	 * Analogous to {@link System#getProperty(String)}.
	 * @param key property name.
	 * @return property value.
	 */
	public @Nullable String valueOrNull(String key);

	/**
	 * Analogous to {@link System#getProperty(String, String)} with the exception that the
	 * fallback cannot be null and the return cannot be null.
	 * @param key property name.
	 * @param fallback returned if the value is not found and may not be null.
	 * @return value found or fallback.
	 */
	default String value(String key, String fallback) {
		Objects.requireNonNull(fallback);
		String v = valueOrNull(key);
		if (v != null) {
			return v;
		}
		return fallback;
	}

	/**
	 * Gets a list or null if the key is missing and by default uses
	 * {@link #parseList(String)}. The default implementation will parse the string value
	 * as a list using {@link LogProperties#parseList(String)} but custom implementations
	 * may rely on their own parsing or the data type is built-in to the backing
	 * configuration system.
	 * <p>
	 * The default format is a single property value that is comma or ampersand separated
	 * and percent-encoded (<a href=
	 * "https://www.w3.org/TR/2014/REC-html5-20141028/forms.html#url-encoded-form-data">
	 * application/x-www-form-urlencoded</a>) for entries needing to contain the separator
	 * or an equals sign, for example: <pre><code>
	 * logging.appenders=console,file
	 * </code></pre> resolves to <code>["console", "file"]</code>, and: <pre><code>
	 * logging.appenders=one%20two,three%2Cfour
	 * </code></pre> (a space and an escaped comma) resolves to
	 * <code>["one two", "three,four"]</code>. See {@link #parseList(String)} for empty
	 * elements.
	 * @param key property name.
	 * @return list or <code>null</code>.
	 * @apiNote the reason empty list is not returned for missing key is that it creates
	 * ambiguity so null is returned when key is missing.
	 */
	default @Nullable List<String> listOrNull(String key) {
		String s = valueOrNull(key);
		if (s == null) {
			return null;
		}
		return parseList(s);
	}

	/**
	 * Gets a map or null if the key is missing and by default uses
	 * {@link #parseMap(String)}. The default implementation will parse the string value
	 * as map using {@link LogProperties#parseMap(String)} but custom implementations may
	 * rely on their own parsing or the data type is built-in to the backing configuration
	 * system.
	 * <p>
	 * The returned map must iterate in a stable order, the same every time for the same
	 * configuration, preferably the order its entries were declared.
	 * <p>
	 * The default format is a single property value that is comma or ampersand separated
	 * <code>key=value</code> pairs, percent-encoded (<a href=
	 * "https://www.w3.org/TR/2014/REC-html5-20141028/forms.html#url-encoded-form-data">
	 * application/x-www-form-urlencoded</a>) for entries needing to contain the separator
	 * or an equals sign, for example: <pre><code>
	 * logging.appender.console.encoder.gelf.headers=environment=prod,region=us-east
	 * </code></pre> resolves to
	 * <code>{"environment": "prod", "region": "us-east"}</code>, and: <pre><code>
	 * logging.appender.console.encoder.gelf.headers=region=us%20east
	 * </code></pre> (a percent-encoded space) resolves to
	 * <code>{"region": "us east"}</code>.
	 * <p>
	 * When that property is not set, the map can instead be given as a list of its keys
	 * in <code>key.keys</code> and each value in <code>key.</code><em>mapKey</em>, which
	 * works with sources that only hold flat values such as system properties or
	 * environment variables, keeps the declared order, and needs no encoding: <pre><code>
	 * logging.appender.console.encoder.gelf.headers.keys=environment,region
	 * logging.appender.console.encoder.gelf.headers.environment=prod
	 * logging.appender.console.encoder.gelf.headers.region=us east
	 * </code></pre> A key listed without a value is an error.
	 * @param key property name.
	 * @return map or <code>null</code>.
	 * @throws IllegalArgumentException if a key listed in <code>key.keys</code> is empty
	 * or has no value.
	 * @apiNote the reason empty map is not returned for missing key is that it creates
	 * ambiguity so null is returned when key is missing.
	 */
	default @Nullable Map<String, String> mapOrNull(String key) {
		String s = valueOrNull(key);
		if (s == null) {
			return PropertyMaps.fromKeys(this, key);
		}
		return parseMap(s);
	}

	/**
	 * Suffix of the property listing a map's keys, see {@link #mapOrNull(String)}.
	 */
	static final String MAP_KEYS_SUFFIX = "keys";

	/**
	 * Visits this properties with visitor, by default just applying visitor to this and
	 * key directly. A composite/aggregate {@link LogProperties} (one backed by more than
	 * one underlying source) overrides this to recursively visit each of its members in
	 * order, returning the first non-<code>null</code> result - which member actually
	 * produced it is exactly what visitor is handed as its first argument, so a composite
	 * never needs to know anything about what visitor is trying to build (a raw value, a
	 * resolved property, or anything else); it just walks its members and stops at the
	 * first hit.
	 * @param <R> result type.
	 * @param key property key.
	 * @param visitor applied to (the member of this properties that ends up handling the
	 * visit, key); a <code>null</code> return means keep visiting.
	 * @return first non-<code>null</code> result, or <code>null</code> if nothing visited
	 * produced one.
	 */
	default <R extends @Nullable Object> @Nullable R visit(String key,
			BiFunction<LogProperties, String, @Nullable R> visitor) {
		return visitor.apply(this, key);
	}

	/**
	 * Searches up a {@value #SEP} separated path using this properties to check for
	 * values. The first value not missing (not <code>null</code>) will be returned.
	 * @param <T> result type
	 * @param root prefix.
	 * @param key partial key that will have prefix concated on each try of func.
	 * @param func convert function where <code>null</code> return means to keep
	 * searching.
	 * @return closest value or <code>null</code> if no value can be found.
	 * @see #findUpPathOrNull(String, Function)
	 */
	@SuppressWarnings("exports")
	default <T extends @Nullable Object> @Nullable T findOrNull(String root, String key,
			BiFunction<LogProperties, String, @Nullable T> func) {
		return findUpPathOrNull(key, k -> func.apply(this, concatKey(root, k)));
	}

	/**
	 * Starts resolving a property under key against this properties - the preferred entry
	 * point for looking up and converting a property, see {@link LogProperty}.
	 * @param key key, must start with {@value #ROOT_PREFIX} and not start or end with
	 * {@value #SEP}.
	 * @return a LogProperty ready to be resolved (e.g. via
	 * {@link LogProperty#ofString()}) or given fallback keys to try (via
	 * {@link LogProperty#or(String)}).
	 * @throws IllegalArgumentException if key is malformed.
	 */
	default LogProperty forKey(String key) {
		return LogProperty.of(this, key);
	}

	/**
	 * Starts resolving a property under key, with a <code>{name}</code> placeholder in
	 * key interpolated to nameParam, against this properties.
	 * @param key key, containing a <code>{name}</code> placeholder, and once interpolated
	 * must start with {@value #ROOT_PREFIX} and not start or end with {@value #SEP}.
	 * @param nameParam value to interpolate for <code>{name}</code> in key.
	 * @return a LogProperty ready to be resolved (e.g. via
	 * {@link LogProperty#ofString()}) or given fallback keys to try (via
	 * {@link LogProperty#or(String, String)}).
	 * @throws IllegalArgumentException if the interpolated key is malformed.
	 */
	default LogProperty forKey(String key, String nameParam) {
		return LogProperty.of(this, key, nameParam);
	}

	/**
	 * Describes a key and by default is usually just the key.
	 * @param key key.
	 * @return usually the key.
	 */
	default String description(String key) {
		return key;
	}

	/**
	 * When log properties are coalesced this method is used to resolve order. A higher
	 * number gives higher precedence.
	 * @return zero by default.
	 */
	default int order() {
		return 0;
	}

	/**
	 * Abstract log properties builder.
	 *
	 * @param <T> builder.
	 * @apiNote this abstract class can largely be ignored hence why it is sealed.
	 */
	public sealed abstract class AbstractBuilder<T> {

		/**
		 * description
		 */
		protected @Nullable String description = null;

		/**
		 * order
		 */
		protected int order = 0;

		/**
		 * rename key
		 */
		protected Function<String, String> renameKey = s -> s;

		/**
		 * fallbacks
		 */
		protected List<LogProperties> fallbacks = new ArrayList<>();

		/**
		 * Ignored.
		 */
		protected AbstractBuilder() {
		}

		/**
		 * Add a fallback properties.
		 * @param properties fallback not null.
		 * @return this.
		 */
		public T with(LogProperties properties) {
			this.fallbacks.add(properties);
			return self();
		}

		/**
		 * For returning this.
		 * @return this builder.
		 */
		protected abstract T self();

		/**
		 * Description for properties.
		 * @param description not null.
		 * @return this.
		 */
		public T description(String description) {
			this.description = description;
			return self();
		}

		/**
		 * When log properties are coalesced this method is used to resolve order. A
		 * higher number gives higher precedence.
		 * @param order order.
		 * @return this.
		 */
		public T order(int order) {
			this.order = order;
			return self();
		}

		/**
		 * Renames the key before it accesses function.
		 * @param renameKey rename key function.
		 * @return this.
		 */
		public T renameKey(Function<String, String> renameKey) {
			this.renameKey = renameKey;
			return self();
		}

		/**
		 * Removes the prefix from a key before it accesses the underlying properties.
		 * @param prefix to be removed from start of key.
		 * @return this.
		 */
		public T removeKeyPrefix(String prefix) {
			return renameKey(k -> LogProperties.removeKeyPrefix(k, prefix));
		}

	}

	/**
	 * Builder for properties.
	 */
	public final static class Builder extends AbstractBuilder<Builder> {

		private @Nullable Function<Builder, LogProperties> provider = null;

		private Builder() {
		}

		@Override
		protected Builder self() {
			return this;
		}

		private Builder provider(Function<Builder, LogProperties> provider) {
			if (this.provider != null) {
				throw new IllegalArgumentException("from already set");
			}
			this.provider = provider;
			return this;
		}

		/**
		 * Sets what is called on {@link LogProperties#valueOrNull(String)}.
		 * @param function valueOrNull func.
		 * @return this.
		 */
		public Builder fromFunction(@SuppressWarnings("exports") Function<String, @Nullable String> function) {
			return provider((b) -> new DefaultLogProperties(function,
					Objects.requireNonNullElse(description, "function"), renameKey, order));
		}

		/**
		 * Parses a string as {@link Properties}.
		 * <p>
		 * The parameter is the <strong>contents</strong> of a properties file, not a
		 * resource, path, or file name. Passing a name such as
		 * {@code "logging.properties"} does not load that file; it is parsed as a single
		 * key with an empty value.
		 * @param properties properties content as a string, for example
		 * {@code "logging.level=INFO"}.
		 * @return this.
		 * @apiNote Rainbow Gum core does not read resources from the filesystem or the
		 * classpath. If external properties are needed, it is the caller's responsibility
		 * to read them into a string (or use a module that does, like
		 * {@code rainbowgum-simple-props}).
		 */
		public Builder fromProperties(String properties) {
			if (description == null) {
				description = "PROPERTIES_STRING";
			}
			return fromFunction(PropertiesParser.readProperties(properties)::get);
		}

		/**
		 * Parses a string as a URI query string. If the URI has no query portion then it
		 * will be equivalent to empty properties.
		 * @param uri percent encoded uri with separator as "<code>&amp;</code>" and key
		 * value separator of "<code>=</code>".
		 * @return this.
		 */
		public Builder fromURIQuery(URI uri) {
			String query = uri.getRawQuery();
			return provider((b) -> queryToProperties(query,
					Objects.requireNonNullElse(description, "URI_QUERY(" + LogRedactor.redactUri(uri) + ")")));
		}

		private MultiMapProperties queryToProperties(@Nullable String query, String description) {
			Map<String, List<String>> multiMap;
			if (query == null) {
				multiMap = Map.of();
			}
			else {
				multiMap = parseMultiMap(query);
			}
			var properties = new MultiMapProperties(multiMap, description, renameKey, order);
			return properties;
		}

		/**
		 * Builds LogProperties based on builder config.
		 * @return this.
		 */
		public LogProperties build() {
			var f = this.provider;
			if (f == null) {
				if (!fallbacks.isEmpty()) {
					return LogProperties.of(fallbacks);
				}
				throw new IllegalStateException("from was not set");
			}

			var first = f.apply(this);
			if (fallbacks.isEmpty()) {
				return first;
			}
			List<LogProperties> combined = new ArrayList<>();
			combined.add(first);
			combined.addAll(fallbacks);
			return LogProperties.of(combined);
		}

		static abstract class AbstractLogProperties implements LogProperties, LogReporter.Reportable {

			protected final String description;

			protected final Function<String, String> renameKey;

			protected final int order;

			public AbstractLogProperties(String description, Function<String, String> renameKey, int order) {
				super();
				this.description = description;
				this.renameKey = renameKey;
				this.order = order;
			}

			@Override
			public int order() {
				return this.order;
			}

			@Override
			public String description(String key) {
				String rename = renameKey.apply(key);
				return descriptionForKey(description, rename);
			}

			@Override
			public void report(Appendable out) throws IOException {
				out.append(description);
			}

			@Override
			public String toString() {
				return getClass().getSimpleName() + "[description='" + description + "', " + "order=" + order + "]";
			}

		}

		private static final class DefaultLogProperties extends AbstractLogProperties {

			private final Function<String, @Nullable String> func;

			DefaultLogProperties(Function<String, @Nullable String> func, String description,
					Function<String, String> renameKey, int order) {
				super(description, renameKey, order);
				this.func = func;
			}

			@Override
			public @Nullable String valueOrNull(String key) {
				return func.apply(renameKey.apply(key));
			}

		}

	}

	/**
	 * Properties that can list the keys they hold, so that with
	 * {@link LogConfig.DebugModeType#HELP} (or {@link LogConfig.DebugModeType#ALL}) keys
	 * that were set but never read can be reported.
	 */
	public interface Listable extends LogProperties {

		/**
		 * The keys this source holds.
		 * @return keys.
		 */
		Collection<String> keys();

	}

	/**
	 * A log properties that can be mutated like a map.
	 */
	public interface MutableLogProperties extends LogProperties {

		/**
		 * Assigns key to a value.
		 * @param key key.
		 * @param value value if null will remove the key.
		 * @return this.
		 */
		MutableLogProperties put(String key, @Nullable String value);

		/**
		 * Builder for MutableLogProperties.
		 * @return builder.
		 */
		public static Builder builder() {
			return new Builder();
		}

		/**
		 * Mutable Log properties builder.
		 */
		public final class Builder extends AbstractBuilder<Builder> {

			private Map<String, String> map = new LinkedHashMap<>();

			private Builder() {
			}

			@Override
			protected Builder self() {
				return this;
			}

			/**
			 * Sets the backing mutable map.
			 * @param map backing map.
			 * @return this.
			 */
			public Builder with(Map<String, String> map) {
				this.map = map;
				return this;
			}

			/**
			 * Parses a string as {@link Properties} useful for unit testing with Java
			 * multiline string literal.
			 * @param properties properties as a string.
			 * @return this.
			 */
			public Builder copyProperties(String properties) {
				map.putAll(PropertiesParser.readProperties(properties));
				return this;
			}

			/**
			 * Builds a mutable log properties.
			 * @return mutable log properties.
			 */
			public MutableLogProperties build() {
				String description = this.description;
				if (description == null) {
					description = "custom mutable";
				}
				var first = new MapLogProperties(map, description, renameKey, order);
				if (fallbacks.isEmpty()) {
					return first;
				}
				List<LogProperties> combined = new ArrayList<>();
				combined.add(first);
				combined.addAll(fallbacks);
				return new CompositeMutableLogProperties(sort(combined));
			}

			private static final class MapLogProperties extends LogProperties.Builder.AbstractLogProperties
					implements MutableLogProperties {

				private final Map<String, String> map;

				MapLogProperties(Map<String, String> map, String description, Function<String, String> renameKey,
						int order) {
					super(description, renameKey, order);
					this.map = map;
				}

				@Override
				public @Nullable String valueOrNull(String key) {
					return map.get(renameKey.apply(key));
				}

				@Override
				public MutableLogProperties put(String key, @Nullable String value) {
					Objects.requireNonNull(key);
					key = renameKey.apply(key);
					if (value == null) {
						map.remove(key);
						return this;
					}
					map.put(key, value);
					return this;
				}

			}

		}

	}

	/**
	 * LogProperties builder.
	 * @return builder.
	 */
	public static Builder builder() {
		return new Builder();
	}

	/**
	 * Finds global properties by checking the currently bound rainbow gum and if not set
	 * use system properties. <strong>This method should be used sparingly and is
	 * preferred that you get the properties through a config instance instead.</strong>
	 * @return currently bound properties or system properties.
	 * @apiNote the intention of this method is to be used for ServiceLoader singleton
	 * components and other systems that maybe bound prior to rainbow gum loading that
	 * need to determine if they should be enabled.
	 */
	public static LogProperties findGlobalProperties() {
		return findGlobalProperties(() -> StandardProperties.SYSTEM_PROPERTIES);
	}

	/**
	 * Finds global properties by checking the currently bound rainbow gum and if not set
	 * use the supplier as the fallback. <strong>This method should be used sparingly and
	 * is preferred that you get the properties through a config instance
	 * instead.</strong>
	 * @param fallback supplier to use if no rainbow gum is bound.
	 * @return currently bound properties or fallback.
	 * @apiNote the intention of this method is to be used for ServiceLoader singleton
	 * components and other systems that maybe bound prior to rainbow gum loading that
	 * need to determine if they should be enabled.
	 */
	public static LogProperties findGlobalProperties(Supplier<? extends LogProperties> fallback) {
		var gum = RainbowGum.getOrNull();
		if (gum != null) {
			return gum.config().properties();
		}
		return Objects.requireNonNull(fallback.get());
	}

	/**
	 * Creates log properties from many log properties. Equal properties are only included
	 * once.
	 * @param logProperties list of properties.
	 * @param fallback if the logProperties list is empty.
	 * @return log properties
	 */
	public static LogProperties of(List<? extends LogProperties> logProperties, LogProperties fallback) {
		if (logProperties.isEmpty()) {
			return fallback;
		}
		if (logProperties.size() == 1) {
			return Objects.requireNonNull(logProperties.get(0));
		}
		var array = sort(logProperties);
		return new CompositeLogProperties(array);
	}

	private static LogProperties[] sort(List<? extends LogProperties> logProperties) {
		@SuppressWarnings("null") // TODO eclipse array issue
		LogProperties[] array = logProperties.stream()
			.filter(p -> p != StandardProperties.EMPTY)
			// The same source listed twice adds nothing but noise in reports and errors.
			.distinct()
			.toArray(size -> new LogProperties[size]);
		Arrays.sort(array, Comparator.comparingInt(LogProperties::order).reversed());
		return array;
	}

	/**
	 * Creates log properties from many log properties.
	 * @param logProperties the list of properties.
	 * @return if the logProperties is empty {@link StandardProperties#EMPTY} will be
	 * used.
	 */
	public static LogProperties of(List<? extends LogProperties> logProperties) {
		return of(logProperties, StandardProperties.EMPTY);
	}

	/**
	 * Creates log properties from a URI query and provided properties. This is useful for
	 * URI based providers where the URI query is contributing properties for the plugin.
	 * @param uri query will be used to pull properties from using
	 * {@link #parseUriQuery(String, BiConsumer)}.
	 * @param prefix part of the key that will stripped before accessing the URI
	 * properties.
	 * @param properties usually this is the properties coming from {@link LogConfig}.
	 * @return combined properties where properties is tried first than the URI query
	 * parameters.
	 */
	public static LogProperties of(URI uri, String prefix, LogProperties properties) {
		return of(uri, prefix, properties, null);
	}

	/**
	 * Creates log properties from a URI query and provided properties. This is useful for
	 * URI based providers where the URI query is contributing properties for the plugin.
	 * @param uri query will be used to pull properties from using
	 * {@link #parseUriQuery(String, BiConsumer)}.
	 * @param prefix part of the key that will stripped before accessing the URI
	 * properties.
	 * @param properties usually this is the properties coming from {@link LogConfig}.
	 * @param uriKey where the URI came from.
	 * @return combined properties where properties is tried first than the URI query
	 * parameters.
	 */
	public static LogProperties of(URI uri, String prefix, LogProperties properties, @Nullable String uriKey) {
		/*
		 * TODO this should be integrated with the whole providers and registries.
		 */
		String propDesc = uriKey != null ? "[" + uriKey + "]->" : "";
		String description = propDesc + "URI(" + LogRedactor.redactUri(uri) + ")";
		var uriProperties = LogProperties.builder()
			.description(description)
			.fromURIQuery(uri)
			.renameKey(s -> LogProperties.removeKeyPrefix(s, prefix))
			.build();
		return LogProperties.of(List.of(properties, uriProperties));
	}

	/**
	 * Parse a {@linkplain URI#getRawQuery() URI query} in <a href=
	 * "https://www.w3.org/TR/2014/REC-html5-20141028/forms.html#url-encoded-form-data">
	 * application/x-www-form-urlencoded </a> format useful for parsing properties values
	 * that have key values embedded in them. <strong>This parser unlike form encoding
	 * uses <code>%20</code> for space as the data is coming from a URI.</strong>
	 * @param query raw query component of URI.
	 * @param consumer accept will be called for each entry and if key (left hand) does
	 * not have a "<code>=</code>" following it the second parameter on the consumer
	 * accept will be passed <code>null</code>.
	 */
	public static void parseUriQuery(String query,
			@SuppressWarnings("exports") BiConsumer<String, @Nullable String> consumer) {
		parseUriQuery(query, true, consumer);
	}

	/**
	 * Parses a URI query formatted string to a Map.
	 * @param query raw query component of URI.
	 * @return decoded key values with last key winning over previous equal keys.
	 * @see #parseUriQuery(String, BiConsumer)
	 */
	public static Map<String, String> parseMap(String query) {
		Map<String, String> m = new LinkedHashMap<>();
		parseUriQuery(query, (k, v) -> {
			if (v != null) {
				m.put(k, v);
			}
		});
		return m;
	}

	/**
	 * Parses a URI query for a multiple value map. The list values of the map maybe empty
	 * if the query parameter does not have any values associated with it which would be
	 * the case if there is a parameter (key) with no "<code>=</code>" following it. For
	 * example the following would have three entries of <code>a,b,c</code> all with empty
	 * list: <pre>
	 * <code>
	 * a&amp;b&amp;c&amp;
	 * </code> </pre>
	 * @param query raw query component of URI.
	 * @return decoded key values with multiple keys grouped together in order found.
	 * @see #parseUriQuery(String, BiConsumer)
	 */
	public static Map<String, List<String>> parseMultiMap(String query) {
		Map<String, List<String>> m = new LinkedHashMap<>();
		BiConsumer<String, @Nullable String> f = (k, v) -> {
			List<String> list = Objects.requireNonNull(m.computeIfAbsent(k, _k -> new ArrayList<String>()));
			if (v != null) {
				list.add(v);
			}
		};
		parseUriQuery(query, f);
		return m;
	}

	/**
	 * Parses a list of strings from a string that is <a href=
	 * "https://www.w3.org/TR/2014/REC-html5-20141028/forms.html#url-encoded-form-data">
	 * percent encoded for escaping (application/x-www-form-urlencoded)</a> where the
	 * separator can be either "<code>&amp;</code>" or "<code>,</code>".
	 * <p>
	 * An example would be using ampersand: <pre><code>
	 *  "a&amp;b%20B&amp;c" -> ["a","b B","c"]
	 *  </code> </pre> and comma: <pre><code>
	 *  "a,b,c" -> ["a","b","c"]
	 *  </code> </pre>
	 * <p>
	 * An element may be empty. One empty element at the start and one at the end are
	 * dropped, so a value assembled from parts that may be missing still works
	 * (<code>"a,"</code> and <code>",a,"</code> are <code>["a"]</code>), while every
	 * other empty element is kept: <pre><code>
	 *  "" -> []
	 *  ",," -> [""]
	 *  "a,,b" -> ["a","","b"]
	 *  "a,," -> ["a",""]
	 *  </code> </pre> An equals sign is part of the element: <code>"a=b"</code> is
	 * <code>["a=b"]</code>.
	 * @param query the comma or ampersand delimited string.
	 * @return list of strings
	 */
	public static List<String> parseList(String query) {
		List<String> list = new ArrayList<>();
		int start = 0;
		for (int i = 0; i < query.length(); i++) {
			char c = query.charAt(i);
			if (c == ',' || c == '&') {
				list.add(query.substring(start, i));
				start = i + 1;
			}
		}
		list.add(query.substring(start));
		if (list.get(0).isEmpty()) {
			list.remove(0);
		}
		if (!list.isEmpty() && list.get(list.size() - 1).isEmpty()) {
			list.remove(list.size() - 1);
		}
		list.replaceAll(e -> PercentCodec.decode(e, StandardCharsets.UTF_8));
		return list;
	}

	private static void parseUriQuery(String query, boolean decode, BiConsumer<String, @Nullable String> consumer) {
		parseUriQuery(query, decode, "[&,]", consumer);
	}

	private static void parseUriQuery(String query, boolean decode, String sep,
			BiConsumer<String, @Nullable String> consumer) {
		/*
		 * TODO default java split has issues but is very fast
		 */
		@SuppressWarnings("StringSplitter")
		String[] pairs = query.split(sep);
		for (String pair : pairs) {
			int idx = pair.indexOf("=");
			String key;
			String value;
			if (idx == 0) {
				continue;
			}
			else if (idx < 0) {
				key = pair;
				value = null;
			}
			else {
				key = pair.substring(0, idx);
				value = pair.substring(idx + 1);
			}
			if (decode) {
				key = PercentCodec.decode(key, StandardCharsets.UTF_8);
				if (value != null) {
					value = PercentCodec.decode(value, StandardCharsets.UTF_8);
				}

			}
			if (key.isBlank()) {
				continue;
			}
			consumer.accept(key, value);
		}
	}

	/**
	 * Common static log properties such as System properties ane environment variables.
	 */
	@CaseChanging
	enum StandardProperties implements LogProperties, LogReporter.Reportable {

		/**
		 * Empty properties. The order is <code>-1</code>.
		 */
		EMPTY {
			@Override
			public @Nullable String valueOrNull(String key) {
				return null;
			}

			@Override
			public int order() {
				return -1;
			}
		},
		/**
		 * Uses {@link System#getProperty(String)} for {@link #valueOrNull(String)}. The
		 * {@link #order()} is <code>400</code> which the same value as MicroProfile
		 * config ordinal.
		 */
		SYSTEM_PROPERTIES {
			@Override
			public @Nullable String valueOrNull(String key) {
				return System.getProperty(key);
			}

			@Override
			public int order() {
				return 400;
			}
		},
		/**
		 * Uses {@link System#getenv(String)} for {@link #valueOrNull(String)}.The
		 * {@link #order()} is <code>300</code> which is the same value as MicroProfile
		 * config ordinal.
		 */
		ENVIRONMENT_VARIABLES {
			@Override
			public @Nullable String valueOrNull(String key) {
				return System.getenv(translateKey(key));
			}

			@Override
			protected String translateKey(String key) {
				return key.replace(".", "_");
			}

			@Override
			public int order() {
				return 300;
			}
		};

		@Override
		public String description(String key) {
			String k = translateKey(key);
			return name() + "[" + k + "]";
		}

		/*
		 * Each constant's own per-constant class body (see EMPTY/SYSTEM_PROPERTIES/
		 * ENVIRONMENT_VARIABLES above) makes getClass() an anonymous subclass with no
		 * canonical name, so without this LogPropertiesReporting's fallback chain would
		 * land on the description(dummyKey) branch instead: name() alone is a cleaner
		 * report for the single most common LogProperties in practice (the default when
		 * nothing else is configured).
		 */
		@Override
		public void report(Appendable out) throws IOException {
			out.append(name());
		}

		/**
		 * Translate the key.
		 * @param key input
		 * @return output
		 */
		protected String translateKey(String key) {
			return key;
		}

	}

	/**
	 * Searches a {@value LogProperties#SEP} separated property path recursing up the path
	 * till a non null value (not missing) is found.
	 * <p>
	 * For example assume we have a single property set like
	 * <code>logging.level.com.stuff=DEBUG</code> and we call this function with
	 * <code>logging.level.com.stuff.a.b</code>.
	 * <ol>
	 * <li><code>logging.level.com.stuff.a.b</code> will resolve to <code>null</code></li>
	 * <li><code>logging.level.com.stuff.a</code> will resolve to <code>null</code></li>
	 * <li><code>logging.level.com.stuff</code> will resolve to <code>"DEBUG"</code>, stop
	 * and return <code>"DEBUG"</code>.</li>
	 * </ol>
	 * @param <T> type to return.
	 * @param key the initial path.
	 * @param resolveFunc function that returns the value at path or <code>null</code>.
	 * @return value or <code>null</code> if no non null value can be found.
	 */
	@SuppressWarnings("exports")
	public static <T extends @Nullable Object> @Nullable T findUpPathOrNull(String key,
			Function<String, @Nullable T> resolveFunc) {
		return searchPath(key, resolveFunc, SEP);
	}

	private static <T> @Nullable T searchPath(String key, Function<String, @Nullable T> resolveFunc, String sep) {
		String tempName = key;
		@Nullable T level = null;
		int indexOfLastDot = tempName.length();
		while ((level == null) && (indexOfLastDot > -1)) {
			tempName = tempName.substring(0, indexOfLastDot);
			level = resolveFunc.apply(tempName);
			indexOfLastDot = tempName.lastIndexOf(sep);
		}
		if (level != null) {
			return level;
		}
		return resolveFunc.apply("");
	}

	/**
	 * Concats a key using {@link #SEP} to the root prefix.
	 * @param name key.
	 * @return concat key.
	 */
	static String concatKey(String name) {
		return concatKey(LogProperties.ROOT_PREFIX, name);
	}

	/**
	 * Concats a keys using {@link #SEP} as the separator.
	 * @param prefix start of key.
	 * @param name second part of key.
	 * @return key.
	 */
	static String concatKey(String prefix, String name) {
		if (name.equals("")) {
			return prefix;
		}
		if (prefix.equals("")) {
			return name;
		}
		if (prefix.endsWith(".")) {
			prefix = prefix.substring(0, prefix.length() - 1);
		}
		if (name.startsWith(SEP)) {
			return prefix + name;
		}
		return prefix + SEP + name;
	}

	/**
	 * Replace "{name}" tokens in property names.
	 * @param key property name.
	 * @param parameters tokens to replace with entry values.
	 * @return interpolated property key.
	 * @see #interpolateKey(String, Function)
	 */
	static String interpolateKey(String key, Map<String, String> parameters) {
		return interpolateKey(key, parameters::get);
	}

	/**
	 * Replaces property <strong>key</strong> parameters by using the lookup function.
	 * Property keys are often parameterized usually by some name. The parameters are
	 * surrounded by curly braces and replaced by the lookup function. An example of the
	 * format is <code>a.{PARAM}</code> which has a parameter named <code>PARAM</code>. If
	 * the lookup function has <code>PARAM->b</code> then the result will be
	 * <code>a.b</code>. If a value for a parameter is null (indicates not found) an
	 * exception is thrown.
	 * @param key to be interpolated.
	 * @param lookup function that may return null for a parameter.
	 * @return interpolated key with all parameters replaced.
	 * @throws IllegalArgumentException if the lookup function does not have a value for a
	 * parameter.
	 */
	public static String interpolateKey(String key,
			@SuppressWarnings("exports") Function<String, ? extends @Nullable String> lookup)
			throws IllegalArgumentException {
		var parameters = keyParameters(key);
		String result = key;
		for (String k : parameters) {
			var v = lookup.apply(k);
			if (v == null) {
				throw new IllegalArgumentException(
						"Keyed parameter missing. key: '" + key + "' parameter: '" + k + "'");
			}
			LogProperty.Validator.validateKeyParameterValue(key, k, v);
			result = result.replace("{" + k + "}", v);
		}
		return result;
	}

	/**
	 * Interpolates a named key which is a property key that has a {@value #NAME}.
	 * @param key with name parameters.
	 * @param name replace name token.
	 * @return interpolated property key.
	 */
	static String interpolateNamedKey(String key, String name) {
		return interpolateKey(key, Map.of(NAME, name));
	}

	/**
	 * Remove key prefix from the key. Prefix should end in "." but this method does not
	 * check that.
	 * @param key key whose prefix will be removed.
	 * @param prefix property name prefix.
	 * @return prefix removed if the string is prefixed.
	 */
	static String removeKeyPrefix(String key, String prefix) {
		if (key.startsWith(prefix)) {
			return key.substring(prefix.length());
		}
		return key;
	}

	/**
	 * Property prefix parameter pattern.
	 */
	static final Pattern PARAMETER_PATTERN = Pattern.compile("\\{(.*?)\\}");

	/**
	 * Extract key parameters with {@link #PARAMETER_PATTERN}.
	 * @param key also known as property name.
	 * @return parameters in key.
	 */
	static Set<String> keyParameters(String key) {
		Set<String> tokens = new LinkedHashSet<>();
		Matcher matcher = PARAMETER_PATTERN.matcher(key);
		while (matcher.find()) {
			var t = matcher.group(1);
			if (t == null) {
				throw new IllegalStateException("bug with parameter pattern regex.");
			}
			tokens.add(t);
		}
		return tokens;
	}

	/**
	 * Validate key parameters.
	 * @param key key to be interpolated with parameters.
	 * @param parameters names of parameters which must <strong>equal</strong> the set of
	 * the found parameters. while the order obviously does not matter the set cannot have
	 * more parameters than found in the key.
	 */
	static void validateKeyParameters(String key, Set<String> parameters) {
		var keyParameters = keyParameters(key);
		if (!parameters.equals(keyParameters)) {
			throw new IllegalArgumentException("Keyed parameter mismatch. key: '" + key + "' , key parameters: "
					+ keyParameters + " provided parameters: " + parameters);
		}
	}

	/**
	 * Standard way to produce description for a key.
	 * @param type the properties type name.
	 * @param key the properties key translated.
	 * @return description for key.
	 */
	static String descriptionForKey(String type, String key) {
		return type + "[" + key + "]";
	}

	/**
	 * Standard way to produce description for a key found in a resource (a file or
	 * similar), without a line number.
	 * @param type the properties type name.
	 * @param resource file like name.
	 * @param key the properties key translated.
	 * @return description for key.
	 */
	static String descriptionForResource(String type, String resource, String key) {
		return type + "[" + resource + "][" + key + "]";
	}

	/**
	 * Standard way to produce description for a key found in a resource (a file or
	 * similar) at a known index, usually a line number.
	 * @param type the properties type name.
	 * @param resource file like name.
	 * @param key the properties key translated.
	 * @param index usually line number of where key found.
	 * @return description for key.
	 */
	static String descriptionForResource(String type, String resource, String key, long index) {
		return type + "[" + resource + ":" + index + "][" + key + "]";
	}

}

final class MultiMapProperties extends AbstractLogProperties {

	private final Map<String, List<String>> multiMap;

	public MultiMapProperties(Map<String, List<String>> multiMap, String description,
			Function<String, String> renameKey, int order) {
		super(description, renameKey, order);
		this.multiMap = multiMap;
	}

	@Override
	public @Nullable String valueOrNull(String key) {
		return _valueOrNull(renameKey.apply(key));
	}

	private @Nullable String _valueOrNull(String key) {
		var list = multiMap.get(key);
		if (list == null || list.isEmpty()) {
			return null;
		}
		return list.get(0);
	}

	@Override
	public @Nullable List<String> listOrNull(String key) {
		return multiMap.get(renameKey.apply(key));
	}

	@Override
	public @Nullable Map<String, String> mapOrNull(String key) {
		String s = valueOrNull(key);
		if (s != null) {
			return LogProperties.parseMap(s);
		}
		var map = PropertyMaps.fromKeys(this, key);
		if (map != null) {
			return map;
		}
		/*
		 * Now we check for dotted notation since key was not set. key.subkey=value
		 */
		String prefix = renameKey.apply(key) + LogProperties.SEP;
		LinkedHashMap<String, String> m = new LinkedHashMap<>();
		for (var k : multiMap.keySet()) {
			if (k.startsWith(prefix) && !k.equals(prefix)) {
				String v = _valueOrNull(k);
				if (v != null) {
					String _k = k.substring(prefix.length());
					m.put(_k, v);
				}
			}
		}
		return m.isEmpty() ? null : m;
	}

}

interface ListLogProperties extends LogProperties, LogReporter.Reportable {

	@Override
	default @Nullable String valueOrNull(String key) {
		for (var props : properties()) {
			var value = props.valueOrNull(key);
			if (value != null) {
				return value;
			}
		}
		return null;
	}

	@Override
	default <R extends @Nullable Object> @Nullable R visit(String key,
			BiFunction<LogProperties, String, @Nullable R> visitor) {
		for (var props : properties()) {
			var value = props.visit(key, visitor);
			if (value != null) {
				return value;
			}
		}
		return null;
	}

	@Override
	default String description(String key) {
		var descriptions = new ArrayList<String>();
		visit(key, (source, sourceKey) -> {
			descriptions.add(source.description(sourceKey));
			return null;
		});
		String description = String.join(", ", descriptions);
		if (description.length() > 120) {
			return "\n    " + String.join(",\n    ", descriptions);
		}
		return description;
	}

	LogProperties[] properties();

	default StringBuilder toString(StringBuilder sb) {
		sb.append(getClass().getSimpleName());
		sb.append("[properties=").append(Arrays.deepToString(properties())).append("]");
		return sb;
	}

	/*
	 * Reports components separated by commas without the error description's line
	 * wrapping (via LogPropertiesReporting.describe, the same fallback chain
	 * LogReporter#appendGlobalProperties uses for the top level LogProperties a LogConfig
	 * was built with) rather than per key.
	 */
	@Override
	default void report(Appendable out) throws IOException {
		boolean first = true;
		for (var p : properties()) {
			if (first) {
				first = false;
			}
			else {
				out.append(", ");
			}
			LogPropertiesReporting.describe(out, p);
		}
	}

}

/*
 * Shared by ListLogProperties#report(Appendable) (recursively, per component) and
 * LogReporter's own appendGlobalProperties(Appendable, LogProperties) (for the top level
 * LogProperties a LogConfig was built with), so there is exactly one fallback chain for
 * "how do we describe an arbitrary LogProperties for a report": an explicit
 * Reportable#report(...) if implemented, else the concrete class' fully qualified name
 * (stable and free: no override needed), else, only for the rare case where the FQCN
 * itself is null (e.g. one of StandardProperties' own per constant enum bodies, which are
 * anonymous classes under the hood, before it implemented Reportable directly),
 * description(key) with a dummy key, since that is the only description() such a
 * LogProperties is guaranteed to answer at all.
 */
final class LogPropertiesReporting {

	static final String DESCRIBE_KEY = "logging.reporter.describe";

	private LogPropertiesReporting() {
	}

	static void describe(Appendable out, LogProperties properties) throws IOException {
		if (properties instanceof LogReporter.Reportable r) {
			r.report(out);
			return;
		}
		String fqcn = properties.getClass().getCanonicalName();
		if (fqcn != null) {
			out.append(fqcn);
			return;
		}
		out.append(properties.description(DESCRIBE_KEY));
	}

}

@SuppressWarnings("ArrayRecordComponent") // TODO we did this originall because it was hot
											// path but not anymore
record CompositeMutableLogProperties(LogProperties[] properties) implements ListLogProperties, MutableLogProperties {

	@Override
	@SuppressWarnings("ReferenceEquality") // identity check to avoid self-recursion if a
											// composite contains itself
	public MutableLogProperties put(String key, @Nullable String value) {
		for (var p : properties()) {
			if (p instanceof MutableLogProperties mp && mp != this) {
				mp.put(key, value);
				return this;
			}
		}
		return this;
	}

	@Override
	public String toString() {
		return toString(new StringBuilder()).toString();
	}

}

@SuppressWarnings("ArrayRecordComponent") // TODO we did this originall because it was hot
											// path but not anymore
record CompositeLogProperties(LogProperties[] properties) implements ListLogProperties {
	@Override
	public String toString() {
		return toString(new StringBuilder()).toString();
	}
}

final class PropertiesParser {

	private PropertiesParser() {
	}

	static Map<String, String> readProperties(String input) {
		Map<String, String> m = new LinkedHashMap<>();
		StringReader sr = new StringReader(input);
		try {
			readProperties(sr, m::put);
		}
		catch (IOException e) {
			throw new UncheckedIOException(e);
		}
		return m;
	}

	static void readProperties(Reader reader, BiConsumer<String, String> consumer) throws IOException {
		Properties bp = prepareProperties(consumer);
		bp.load(reader);
	}

	private static Properties prepareProperties(BiConsumer<String, String> consumer) {
		// Hack to use properties class to load but our map for preserved order
		@SuppressWarnings({ "serial", "nullness" })
		Properties bp = new Properties() {
			@Override
			@SuppressWarnings({ "nullness", "keyfor", "UnsynchronizedOverridesSynchronized" }) // checker
																								// bug
			public @Nullable Object put(Object key, Object value) {
				Objects.requireNonNull(key);
				Objects.requireNonNull(value);
				consumer.accept((String) key, (String) value);
				return null;
			}
		};
		return bp;
	}

}

/*
 * The key.keys form of LogProperties.mapOrNull, shared with implementations that override
 * it.
 */
final class PropertyMaps {

	private PropertyMaps() {
	}

	static @Nullable Map<String, String> fromKeys(LogProperties properties, String key) {
		String keysKey = key + LogProperties.SEP + LogProperties.MAP_KEYS_SUFFIX;
		List<String> keys = properties.listOrNull(keysKey);
		if (keys == null) {
			return null;
		}
		Map<String, String> m = new LinkedHashMap<>();
		for (String k : keys) {
			if (k.isEmpty()) {
				throw new IllegalArgumentException("'" + keysKey + "' has an empty map key");
			}
			String valueKey = key + LogProperties.SEP + k;
			String v = properties.valueOrNull(valueKey);
			if (v == null) {
				throw new IllegalArgumentException(
						"map key '" + k + "' is listed in '" + keysKey + "' but '" + valueKey + "' is missing");
			}
			m.put(k, v);
		}
		return m;
	}

}