LevelResolver.java

package io.jstach.rainbowgum;

import java.lang.System.Logger.Level;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Stream;

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;

import io.jstach.rainbowgum.LevelResolver.LevelConfig;

/**
 * Resolves levels from logger names.
 *
 * @apiNote {@linkplain Level#ALL} is considered to be equivalent to null or unspecified.
 */
public interface LevelResolver {

	/**
	 * Determines what level the logger should be at. {@linkplain Level#ALL} is to signal
	 * that nothing was resolved.
	 * @param name logger name.
	 * @return resolved level or {@link Level#ALL} if unable to resolve.
	 */
	public Level resolveLevel(String name);

	/**
	 * Convenience method that checks if the loggers resolved level is less than the
	 * passed in level.
	 * @param loggerName logger name.
	 * @param level level.
	 * @return true if logger is less than passed in level.
	 */
	default boolean isEnabled(String loggerName, Level level) {
		return checkEnabled(level, resolveLevel(loggerName));
	}

	/**
	 * Clears any caching of levels.
	 */
	default void clear() {

	}

	/**
	 * A special resolver that has direct mappings of logger name to level via
	 * {@link #levelOrNull(String)}.
	 */
	@FunctionalInterface
	interface LevelConfig extends LevelResolver {

		/**
		 * Returns a level exactly matching a logger name.
		 * @param name logger name.
		 * @return level or <code>null</code>.
		 */
		@Nullable Level levelOrNull(String name);

		/**
		 * The default level if no level is found. By default if not overridden this is
		 * calculated by calling {@link #levelOrNull(String)} with an empty String and if
		 * it returns <code>null</code> ALL is returned.
		 * @return level
		 */
		default Level defaultLevel() {
			var root = levelOrNull("");
			if (root == null) {
				return Level.ALL;
			}
			return root;
		}

		/**
		 * Resolves a level by walking up the dotted path by calling
		 * {@link #levelOrNull(String)} which follows <a href=
		 * "https://logback.qos.ch/manual/architecture.html#effectiveLevel">Logbacks
		 * Effective Level</a>. {@inheritDoc}
		 */
		@Override
		default Level resolveLevel(String name) {
			return resolveLevel(this, name);
		}

		/**
		 * Creates a level config from a list of level configs.
		 * @param config configs.
		 * @return coalesced config.
		 */
		public static LevelConfig of(Collection<? extends LevelConfig> config) {
			if (config.isEmpty()) {
				return StaticLevelResolver.OFF;
			}
			else if (config.size() == 1) {
				return Objects.requireNonNull(config.iterator().next());
			}
			return new CompositeLevelConfig(config.stream().flatMap(c -> {
				if (c instanceof CompositeLevelConfig cc) {
					return Stream.of(cc.levelConfigs());
				}
				return Stream.of(c);
			}).distinct().toList().toArray(new LevelConfig[] {}));
		}

		private static Level resolveLevel(LevelConfig levelBindings, String name) {
			Function<String, @Nullable Level> f = s -> allToNull(levelBindings.levelOrNull(s));
			var level = LogProperties.findUpPathOrNull(name, f);
			if (level != null) {
				return level;
			}
			return levelBindings.defaultLevel();
		}

		private static @Nullable Level allToNull(@Nullable Level level) {
			if (level == null || level == Level.ALL) {
				return null;
			}
			return level;
		}

	}

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

	/**
	 * A level resolver that is off.
	 * @return level resolver that is {@link Level#OFF}.
	 */
	public static LevelResolver off() {
		return StaticLevelResolver.OFF;
	}

	/**
	 * Statically checks if requested level is greater than or equal to the logger level
	 * based on LevelResolver rules.
	 * <p>
	 * Rules are:
	 * <ol>
	 * <li><code>OFF</code> for either level or logger level is <code>false</code></li>
	 * <li><code>ALL</code> is converted to <code>TRACE</code> for both level and
	 * loggerLevel</li>
	 * </ol>
	 * @param level if OFF will always return false.
	 * @param loggerLevel if OFF will always return false.
	 * @return true if level is greater or equal to loggerLevel unless either is OFF.
	 * @apiNote this method is mainly used for testing purposes and is not designed with
	 * performance in mind. In facades it is better to get an integer and use that for
	 * comparison.
	 */
	public static boolean checkEnabled(System.Logger.Level level, System.Logger.Level loggerLevel) {
		if (level == System.Logger.Level.OFF) {
			return false;
		}
		if (loggerLevel == System.Logger.Level.OFF) {
			return false;
		}
		level = normalizeLevel(level);
		loggerLevel = normalizeLevel(loggerLevel);
		return level.getSeverity() >= loggerLevel.getSeverity();
	}

	/**
	 * Parses a Level from a string. ALL is converted to TRACE.
	 * @param input level like String where case is ignored and SLF4J and JUL level names
	 * are supported as well.
	 * @return level
	 * @throws IllegalArgumentException if the input is not recognized as a level.
	 */
	public static Level parseLevel(String input) throws IllegalArgumentException {
		String normalized = input.toUpperCase(Locale.ROOT);
		return switch (normalized) {
			case "ALL" -> Level.TRACE;
			case "TRACE", "FINEST" -> Level.TRACE;
			case "DEBUG", "FINE" -> Level.DEBUG;
			case "INFO" -> Level.INFO;
			case "WARN", "WARNING" -> Level.WARNING;
			case "ERROR", "SEVERE" -> Level.ERROR;
			case "OFF" -> Level.OFF;
			default -> LogProperty.enumValue(Level.class, input, "finest", "fine", "warn", "severe");
		};
	}

	/**
	 * Will convert ALL to TRACE.
	 * @param level level
	 * @return a Level.
	 */
	public static Level normalizeLevel(Level level) {
		return Builder.allToTrace(level);
	}

	/**
	 * Abstract level resolver builder.
	 *
	 * @param <T> builder type.
	 * @apiNote while this builder class is public it is effectively package protected per
	 * the constructor on purpose. It is public to allow Javadoc to propagate correctly.
	 */
	abstract class AbstractBuilder<T> {

		/**
		 * level configs.
		 */
		protected List<LevelConfig> levelConfigs = new ArrayList<>();

		/**
		 * levels
		 */
		protected Map<String, Level> levels = new LinkedHashMap<>();

		/**
		 * Do nothing constructor.
		 */
		AbstractBuilder() {
		}

		/**
		 * Sets a logger to a level.
		 * @param level level.
		 * @param loggerName logger name.
		 * @return this builder.
		 */
		public T level(Level level, String loggerName) {
			levels.put(loggerName, level);
			return self();
		}

		/**
		 * Sets a level for all loggers.
		 * @param level level.
		 * @return this builder.
		 */
		public T level(Level level) {
			levels.put("", level);
			return self();
		}

		/**
		 * Adds a level config.
		 * @param resolver level config.
		 * @return this builder.
		 */
		public T config(LevelConfig resolver) {
			levelConfigs.add(resolver);
			return self();
		}

		/**
		 * Adds a level config based on properties and will use the prefix as the root.
		 * @param properties properties containing levels with prefix.
		 * @param prefix for example <code>logging.level</code>
		 * @return this.
		 */
		public T config(LogProperties properties, String prefix) {
			config(ConfigLevelResolver.of(properties, prefix));
			return config(GroupLevelResolver.of(properties, prefix));
		}

		/**
		 * Builds the level config based on {@link #levelConfigs} and {@link #levels}.
		 * @return level config or <code>null</code>.
		 */
		protected @Nullable LevelConfig buildLevelConfigOrNull() {
			var copyLevels = new LinkedHashMap<>(levels);
			var copyResolvers = new ArrayList<>(levelConfigs);
			if (copyLevels.isEmpty() && copyResolvers.isEmpty()) {
				return null;
			}
			if (!copyLevels.isEmpty()) {
				copyResolvers.add(0, Builder.ofStaticMap(copyLevels));
			}
			var combined = LevelConfig.of(copyResolvers);
			return combined;
		}

		/**
		 * This.
		 * @return this.
		 */
		protected abstract T self();

	}

	/**
	 * Level resolver builder. {@link LevelConfig} that are added have higher precedence.
	 */
	public final class Builder extends AbstractBuilder<Builder> {

		/**
		 * level resolvers.
		 */
		private final List<LevelResolver> levelResolvers = new ArrayList<>();

		/**
		 * lower priority fallback resolvers.
		 */
		private final List<LevelResolver> fallbacks = new ArrayList<>();

		private Builder() {
		}

		/**
		 * Builds a level resolver combining {@link #config(LevelConfig)},
		 * {@link #level(Level, String)}, and {@link #resolver(LevelResolver)} into a
		 * single resolver of equal priority (the highest severity/most restrictive level
		 * found for a logger name wins), and, if any {@link #fallback(LevelResolver)
		 * fallbacks} were added, wrapping that in a priority chain where the fallbacks
		 * are only consulted for logger names the combined resolver above has no opinion
		 * on (resolves to {@link Level#ALL}). The result is cached - deferred as a
		 * {@link LogProvider} (rather than building the cached resolver directly) since
		 * the cache also needs {@link LogConfig#alerts()} to report a logger name whose
		 * level property fails to resolve, and only a couple of resolver primitives (this
		 * builder's cache, and separately the global resolver's own alerting wrapper)
		 * need alerts at all - not every {@link LevelResolver}/{@link LevelConfig}
		 * implementation.
		 * @return level resolver provider.
		 */
		public LogProvider<LevelResolver> build() {
			var config = buildLevelConfigOrNull();
			List<LevelResolver> copy = new ArrayList<>();
			if (config != null) {
				copy.add(config);
			}
			copy.addAll(levelResolvers);
			LevelResolver resolver = ofResolvers(copy);
			if (!fallbacks.isEmpty()) {
				List<LevelResolver> priority = new ArrayList<>();
				priority.add(resolver);
				priority.addAll(fallbacks);
				resolver = PriorityLevelResolver.of(priority);
			}
			var _resolver = resolver;
			return (name, c) -> cached(_resolver, c.alerts());
		}

		/**
		 * Adds a level resolver to the end of the resolve list. Resolvers added this way
		 * are combined with equal priority: for a given logger name the most restrictive
		 * level found across all of them (including {@link #config(LevelConfig)} /
		 * {@link #level(Level, String)}) wins.
		 * @param resolver level resolver.
		 * @return this
		 */
		public Builder resolver(LevelResolver resolver) {
			this.levelResolvers.add(resolver);
			return this;
		}

		/**
		 * Adds a lower priority fallback level resolver. Fallbacks are tried in the order
		 * added and only consulted for a logger name if every resolver ahead of it
		 * (everything from {@link #config(LevelConfig)}, {@link #level(Level, String)},
		 * {@link #resolver(LevelResolver)}, and any earlier added fallback) has no
		 * opinion for that name, i.e. resolves to {@link Level#ALL}. This is useful for
		 * e.g. giving a route's own level configuration priority over a global level
		 * resolver without a more specific match in the global resolver being able to
		 * win.
		 * @param resolver fallback level resolver.
		 * @return this
		 */
		public Builder fallback(LevelResolver resolver) {
			this.fallbacks.add(resolver);
			return this;
		}

		/**
		 * This.
		 * @return this.
		 */
		@Override
		protected Builder self() {
			return this;
		}

		static LevelResolver ofResolvers(Collection<? extends LevelResolver> resolvers) {
			if (resolvers.isEmpty()) {
				return StaticLevelResolver.ALL;
			}
			else if (resolvers.size() == 1) {
				return Objects.requireNonNull(resolvers.iterator().next());
			}
			return CompositeLevelResolver.of(resolvers);
		}

		static LevelConfig ofStaticMap(Map<String, Level> levels) {
			if (levels.isEmpty()) {
				return StaticLevelResolver.ALL;
			}
			else if (levels.size() == 1) {
				var e = levels.entrySet().iterator().next();
				if (e.getKey().equals("")) {
					return StaticLevelResolver.of(e.getValue());
				}
				return new SingleLevelResolver(e.getKey(), allToTrace(e.getValue()));
			}
			Map<String, Level> copy = new HashMap<>();

			for (var et : levels.entrySet()) {
				copy.put(et.getKey(), allToTrace(et.getValue()));
			}
			return new MapLevelResolver(copy);
		}

		static Level allToTrace(Level level) {
			if (level == Level.ALL) {
				return Level.TRACE;
			}
			return level;
		}

		static LevelResolver cached(LevelResolver resolver, LogAlerts alerts) {
			if (resolver instanceof StaticLevelResolver) {
				return resolver;
			}
			return new CachedLevelResolver(resolver, alerts);
		}

	}

}

enum StaticLevelResolver implements LevelConfig {

	ALL(Level.ALL), //
	TRACE(Level.TRACE), //
	INFO(Level.INFO), //
	DEBUG(Level.DEBUG), //
	ERROR(Level.ERROR), //
	WARNING(Level.WARNING), //
	OFF(Level.OFF),; //

	private final Level level;

	private StaticLevelResolver(Level level) {
		this.level = level;
	}

	static LevelConfig of(Level level) {
		return switch (level) {
			case INFO -> StaticLevelResolver.INFO;
			case ALL -> StaticLevelResolver.TRACE; // This is on purpose
			case DEBUG -> StaticLevelResolver.DEBUG;
			case ERROR -> StaticLevelResolver.ERROR;
			case OFF -> StaticLevelResolver.OFF;
			case TRACE -> StaticLevelResolver.TRACE;
			case WARNING -> StaticLevelResolver.WARNING;
		};
	}

	@Override
	public @Nullable Level levelOrNull(String name) {
		if ("".equals(name)) {
			return this.level;
		}
		return null;
	}

	@Override
	public Level resolveLevel(String name) {
		return this.level;
	}

}

record SingleLevelResolver(String name, Level level) implements LevelConfig {

	@Override
	public @Nullable Level levelOrNull(String name) {
		if (this.name.equals(name)) {
			return this.level;
		}
		return null;
	}

}

record MapLevelResolver(Map<String, Level> levels) implements LevelConfig {
	@Override
	public @Nullable Level levelOrNull(String name) {
		return levels.get(name);
	}
}

@SuppressWarnings("ArrayRecordComponent")
record CompositeLevelConfig(LevelConfig[] levelConfigs) implements LevelConfig {

	@Override
	public @Nullable Level levelOrNull(String name) {
		for (var c : levelConfigs) {
			var level = c.levelOrNull(name);
			if (level != null) {
				return level;
			}
		}
		return null;
	}

	@Override
	public String toString() {
		return this.getClass().getSimpleName() + Arrays.asList(levelConfigs);
	}

}

@SuppressWarnings("ArrayRecordComponent")
record CompositeLevelResolver(LevelResolver[] resolvers, Level defaultLevel) implements LevelResolver {

	public static LevelResolver of(Collection<? extends LevelResolver> resolvers) {
		List<LevelResolver> resolved = new ArrayList<>();

		for (var r : resolvers) {
			if (r instanceof CompositeLevelResolver cr) {
				for (var j : cr.resolvers()) {
					resolved.add(j);
				}
			}
			else {
				resolved.add(r);
			}
		}
		@SuppressWarnings("null") // TODO eclipse bug
		LevelResolver @NonNull [] array = resolved.toArray(new LevelResolver[] {});
		return new CompositeLevelResolver(array, Level.OFF);
	}

	@Override
	public String toString() {
		return this.getClass().getSimpleName() + Arrays.asList(resolvers);
	}

	@Override
	public Level resolveLevel(String name) {
		Level current = Level.ALL;
		for (var resolver : resolvers) {
			var level = resolver.resolveLevel(name);
			if (level == Level.TRACE) {
				return level;
			}
			if (current == Level.ALL) {
				current = level;
				continue;
			}
			if (current.getSeverity() > level.getSeverity()) {
				current = level;
			}
		}
		if (current == Level.ALL) {
			current = defaultLevel;
		}
		return current;
	}

	@Override
	public void clear() {
		for (var resolver : resolvers) {
			resolver.clear();
		}
	}

}

/*
 * Revisit perf. ConcurrentHashMap based on benchmarks is incredibly slow for most cases
 * and looping can be faster.
 *
 * Luckily level resolution is only called when a logger is created and the loggers are
 * cached.
 */
/*
 * Resolves against resolvers in order and returns the first non ALL result. This gives
 * earlier resolvers priority per logger name rather than merging all resolvers into one
 * combined prefix walk, where a more specific match in a lower priority resolver could
 * otherwise win over a coarser match in a higher priority one regardless of which is
 * meant to take precedence.
 */
@SuppressWarnings("ArrayRecordComponent")
record PriorityLevelResolver(LevelResolver[] resolvers) implements LevelResolver {

	public static LevelResolver of(Collection<? extends LevelResolver> resolvers) {
		List<LevelResolver> resolved = new ArrayList<>();
		for (var r : resolvers) {
			if (r instanceof PriorityLevelResolver pr) {
				for (var j : pr.resolvers()) {
					resolved.add(j);
				}
			}
			else {
				resolved.add(r);
			}
		}
		if (resolved.isEmpty()) {
			return StaticLevelResolver.ALL;
		}
		if (resolved.size() == 1) {
			return resolved.get(0);
		}
		@SuppressWarnings("null") // TODO eclipse bug
		LevelResolver @NonNull [] array = resolved.toArray(new LevelResolver[] {});
		return new PriorityLevelResolver(array);
	}

	@Override
	public Level resolveLevel(String name) {
		for (var resolver : resolvers) {
			var level = resolver.resolveLevel(name);
			if (level != Level.ALL) {
				return level;
			}
		}
		return Level.ALL;
	}

	@Override
	public void clear() {
		for (var resolver : resolvers) {
			resolver.clear();
		}
	}

	@Override
	public String toString() {
		return this.getClass().getSimpleName() + Arrays.asList(resolvers);
	}

}

final class CachedLevelResolver implements LevelResolver {

	private final LevelResolver levelResolver;

	private final LogAlerts alerts;

	private final ConcurrentHashMap<String, Level> levelCache = new ConcurrentHashMap<>();

	public CachedLevelResolver(LevelResolver levelResolver, LogAlerts alerts) {
		super();
		this.levelResolver = levelResolver;
		this.alerts = alerts;
	}

	@Override
	public Level resolveLevel(String name) {
		return levelCache.computeIfAbsent(name, n -> {
			try {
				return levelResolver.resolveLevel(n);
			}
			catch (Exception e) {
				/*
				 * A malformed level property must not be able to break logging itself.
				 * ConcurrentHashMap#computeIfAbsent leaves nothing cached when the
				 * mapping function throws, so without catching here a bad property would
				 * re-parse and re-throw on every single resolveLevel(n) call for this
				 * logger name, not just once - a real amplification risk for a hot
				 * logger. Fall back to a fixed, conservative default (Level.INFO,
				 * matching LogProperties' own documented root default) and alert exactly
				 * once per logger name instead, since the fallback value is itself cached
				 * above just like a successful resolution would be.
				 */
				alerts.error(CachedLevelResolver.class,
						"Failed to resolve level for logger '" + n + "', falling back to " + Level.INFO, e);
				return Level.INFO;
			}
		});
	}

	@Override
	public String toString() {
		return this.getClass().getSimpleName() + "[" + levelResolver + "]";
	}

	@Override
	public void clear() {
		levelCache.clear();
		levelResolver.clear();
	}

}

/**
 * Wraps the global {@link LevelConfig} (see
 * {@code LogConfig.Builder.buildGlobalResolver}) with the same
 * alert-on-failure/fallback-to-INFO safety net as {@link CachedLevelResolver} gives every
 * per-route resolver, but implemented at the {@link #levelOrNull(String)} level (not
 * {@link #resolveLevel(String)}) so {@link LevelConfig#defaultLevel()} stays available -
 * {@code LogConfig.levelResolver()} is declared to return {@link LevelConfig}, not just
 * {@link LevelResolver}, and real callers use {@code defaultLevel()}. Unlike
 * {@link CachedLevelResolver} this does not cache successful lookups (the global resolver
 * never has), only failed ones, purely so a bad property alerts once per logger name
 * instead of on every call.
 * <p>
 * Special-cased outside the normal {@code LevelResolver.Builder.build()} ->
 * {@code LogProvider<LevelResolver>} path since the global resolver is built before a
 * {@link LogConfig} exists to pull {@link LogAlerts} from - see where this is constructed
 * for why {@link LogAlerts} (and {@link LogMetrics}, via alerts' own listener wiring) are
 * built and handed in directly instead.
 */
final class AlertingLevelConfig implements LevelConfig {

	private final LevelConfig levelConfig;

	private final LogAlerts alerts;

	private final ConcurrentHashMap<String, Level> failedNames = new ConcurrentHashMap<>();

	AlertingLevelConfig(LevelConfig levelConfig, LogAlerts alerts) {
		this.levelConfig = levelConfig;
		this.alerts = alerts;
	}

	@Override
	public @Nullable Level levelOrNull(String name) {
		var fallback = failedNames.get(name);
		if (fallback != null) {
			return fallback;
		}
		try {
			return levelConfig.levelOrNull(name);
		}
		catch (Exception e) {
			alerts.error(AlertingLevelConfig.class,
					"Failed to resolve level for logger '" + name + "', falling back to " + Level.INFO, e);
			failedNames.putIfAbsent(name, Level.INFO);
			return Level.INFO;
		}
	}

	@Override
	public void clear() {
		failedNames.clear();
		levelConfig.clear();
	}

	@Override
	public String toString() {
		return "AlertingLevelConfig[" + levelConfig + "]";
	}

}

final class GroupLevelResolver implements LevelConfig {

	private final LogProperties properties;

	private final String groupLevelPrefix;

	private volatile LevelConfig levelResolver;

	public static GroupLevelResolver of(LogProperties properties) {
		return new GroupLevelResolver(properties, LogProperties.LEVEL_PREFIX);
	}

	public static GroupLevelResolver of(LogProperties properties, String groupLevelPrefix) {
		return new GroupLevelResolver(properties, groupLevelPrefix);
	}

	GroupLevelResolver(LogProperties properties, String groupLevelPrefix) {
		super();
		this.properties = properties;
		this.groupLevelPrefix = groupLevelPrefix;
		this.levelResolver = populate(properties, this.groupLevelPrefix);
	}

	static final String GROUPS_PROPERTY = LogProperties.GROUPS_PROPERTY;
	static final String GROUP_PROPERTY = LogProperties.GROUP_PROPERTY;

	@Override
	public Level resolveLevel(String name) {
		return levelResolver.resolveLevel(name);
	}

	@Override
	public @Nullable Level levelOrNull(String name) {
		return levelResolver.levelOrNull(name);
	}

	@Override
	public void clear() {
		this.levelResolver = populate(properties, this.groupLevelPrefix);
	}

	static LevelConfig populate(LogProperties properties, String groupLevelPrefix) {

		Map<String, List<String>> groupToLoggers = properties.forKey(GROUPS_PROPERTY).ofList().map(_groups -> {
			Map<String, List<String>> m = new LinkedHashMap<>();
			for (String g : _groups) {
				rawValue(properties.forKey(GROUP_PROPERTY, g).ofList()).optional()
					.filter(loggers -> !loggers.isEmpty())
					.ifPresent(loggers -> m.put(g, loggers));
			}
			return m;
		}).or(Map.of()).validateNow(GroupLevelResolver.class);

		Map<String, Level> groupToLevels = new LinkedHashMap<>();
		for (var e : groupToLoggers.entrySet()) {
			String group = e.getKey();
			rawValue(properties.forKey(LogProperties.concatKey(groupLevelPrefix, group))
				.ofString()
				.map(LevelResolver::parseLevel)).optional().ifPresent(level -> groupToLevels.put(group, level));
		}
		Map<String, Level> loggerToLevels = new LinkedHashMap<>();
		for (var e : groupToLevels.entrySet()) {
			/*
			 * groupToLevels's keys are always a subset of groupToLoggers's own keys
			 * (built by iterating groupToLoggers.entrySet() above), whose values are
			 * already guaranteed non-empty by the filter() when groupToLoggers was
			 * populated - so this lookup can never actually be null or empty.
			 */
			List<String> loggers = Objects.requireNonNull(groupToLoggers.get(e.getKey()));
			for (var logger : loggers) {
				loggerToLevels.put(logger, e.getValue());
			}
		}
		return new MapLevelResolver(Map.copyOf(loggerToLevels));
	}

	/*
	 * Both group-loggers and group-level lookups above are optional (a Missing group or
	 * group-level entry is fine, just skipped) but must still surface a present-but
	 * malformed value's raw, unwrapped exception - not a "Validation failed for
	 * GroupLevelResolver:" wrapping, which would also change the exception's public type
	 * away from PropertyConvertException. LogProperty.Result no longer exposes
	 * optional()/value() itself (a bare Result might still be Missing), so this just
	 * narrows to LogProperty.ValidatedResult - true for every concrete Result, Missing
	 * included - without going through a Validator at all.
	 */
	private static <T> LogProperty.ValidatedResult<T> rawValue(LogProperty.Result<T> result) {
		return switch (result) {
			case LogProperty.ValidatedResult<T> vr -> vr;
		};
	}

	@Override
	public String toString() {
		return "GroupLevelResolver[groupLevelPrefix=" + groupLevelPrefix + ", properties=" + properties + "]";
	}

}

final class ConfigLevelResolver implements LevelConfig {

	private final LogProperties properties;

	private final String prefix;

	public static ConfigLevelResolver of(LogProperties properties) {
		return of(properties, LogProperties.LEVEL_PREFIX);
	}

	public static ConfigLevelResolver of(LogProperties properties, String prefix) {
		return new ConfigLevelResolver(properties, prefix);
	}

	private ConfigLevelResolver(LogProperties properties, String prefix) {
		super();
		this.properties = properties;
		this.prefix = prefix;
	}

	@Override
	public @Nullable Level levelOrNull(String name) {
		return rawValue(
				properties.forKey(LogProperties.concatKey(prefix, name)).ofString().map(LevelResolver::parseLevel))
			.valueOrNull();
	}

	/*
	 * Same rationale as GroupLevelResolver's own rawValue(): optional (a Missing level
	 * override is fine), but a present-but-malformed value must still surface its raw,
	 * unwrapped PropertyConvertException rather than a Validator-wrapped one.
	 */
	private static <T> LogProperty.ValidatedResult<T> rawValue(LogProperty.Result<T> result) {
		return switch (result) {
			case LogProperty.ValidatedResult<T> vr -> vr;
		};
	}

	@Override
	public String toString() {
		return "ConfigLevelResolver[prefix=" + prefix + ", properties=" + properties + "]";
	}

}