LogProperty.java

package io.jstach.rainbowgum;

import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;

import org.jspecify.annotations.Nullable;

import io.jstach.rainbowgum.annotation.CaseChanging;

/**
 * Describes a key (or several fallback keys) resolved against a {@link LogProperties}
 * instance - start with {@link LogProperties#forKey(String)} - and also serves as the
 * namespace for the shared property machinery: {@link Result}, {@link Validator}, and the
 * property-specific exceptions.
 *
 * @see LogProperties
 */
@CaseChanging
public interface LogProperty {

	/**
	 * The first key tried.
	 * @return key.
	 */
	public String key();

	/**
	 * Every key tried, in order - the first key given to
	 * {@link LogProperties#forKey(String)}/{@link LogProperties#forKey(String, String)}
	 * followed by any added via {@link #or(String)}/{@link #or(String, String)}.
	 * @return keys.
	 */
	public List<String> keys();

	/**
	 * The properties this key (or keys) will be looked up against.
	 * @return properties.
	 */
	public LogProperties properties();

	/**
	 * Resolves the property as a raw string.
	 * @return result.
	 */
	public Result<String> ofString();

	/**
	 * Resolves the property as a list, using {@link LogProperties#listOrNull(String)}.
	 * @return result.
	 */
	public Result<List<String>> ofList();

	/**
	 * Resolves the property as a map, using {@link LogProperties#mapOrNull(String)}.
	 * @return result.
	 */
	public Result<Map<String, String>> ofMap();

	/**
	 * Parses a case-insensitive enum value and reports the valid property values when
	 * parsing fails. Enum names and additional values are shown in lowercase, as property
	 * values conventionally are.
	 * @param <E> enum type.
	 * @param enumType enum class.
	 * @param value input value.
	 * @param additionalValues accepted aliases or other values supported by the property.
	 * @return parsed enum value.
	 */
	static <E extends Enum<E>> E enumValue(Class<E> enumType, String value, String... additionalValues) {
		try {
			return Enum.valueOf(enumType, value.toUpperCase(Locale.ROOT));
		}
		catch (IllegalArgumentException e) {
			var available = new LinkedHashSet<String>();
			EnumSet.allOf(enumType).stream().map(v -> v.name().toLowerCase(Locale.ROOT)).forEach(available::add);
			Arrays.stream(additionalValues).map(v -> v.toLowerCase(Locale.ROOT)).forEach(available::add);
			throw new IllegalArgumentException("'" + value + "' is not a valid value for " + enumType.getCanonicalName()
					+ ". Valid values: " + String.join(", ", available.stream().map(v -> "'" + v + "'").toList()), e);
		}
	}

	/**
	 * Renders a URI for diagnostics with user information and sensitive query values
	 * redacted. The returned text is for display, not for making requests.
	 * @param uri URI to display.
	 * @return redacted URI text.
	 */
	static String redactUri(URI uri) {
		return LogRedactor.redactUri(uri);
	}

	/**
	 * Resolves the property as an int.
	 * @return result.
	 */
	default Result<Integer> ofInt() {
		return ofString().map(Integer::parseInt);
	}

	/**
	 * Resolves the property as a long.
	 * @return result.
	 */
	default Result<Long> ofLong() {
		return ofString().map(Long::parseLong);
	}

	/**
	 * Resolves the property as a boolean.
	 * <p>
	 * {@link Boolean#parseBoolean(String)} treats any value other than
	 * <code>"true"</code> (case-insensitive) as <code>false</code> - a typo or a
	 * reasonable-looking alternative like <code>someFlag=yes</code> silently resolves to
	 * <code>false</code> instead of failing loudly. Component/plugin authors implementing
	 * a new property should prefer a small enum (with
	 * <code>true</code>/<code>false</code> aliased to e.g.
	 * <code>on</code>/<code>off</code> members) parsed via {@link #ofString()} instead,
	 * so a bad value alerts rather than silently resolving false. This method remains
	 * available (and fine) for end users setting an existing boolean property.
	 * @return result.
	 */
	default Result<Boolean> ofBoolean() {
		return ofString().map(Boolean::parseBoolean);
	}

	/**
	 * Resolves the property as a URI.
	 * @return result.
	 */
	default Result<URI> ofURI() {
		return ofString().map(URI::new);
	}

	/**
	 * Resolves the property as a {@link LogProviderRef} for component provision.
	 * @return result.
	 * @see LogOutput#of(LogProviderRef)
	 */
	default Result<LogProviderRef> ofProviderRef() {
		return switch (ofURI()) {
			case Result.Success<URI> s -> mapValue(s, LogProviderRef.of(s.value(), s.key()));
			case Result.Missing<URI> m -> m.convert();
			case Result.Error<URI> e -> e.convert();
		};
	}

	/**
	 * Resolves the property as a {@link LogProviderRef} and then maps it to a
	 * {@link LogProvider}.
	 * @param <U> provider type.
	 * @param mapper function to map to provider.
	 * @return result.
	 * @see LogOutput#of(LogProviderRef)
	 */
	default <U> Result<LogProvider<U>> ofProvider(
			PropertyFunction<LogProviderRef, LogProvider<U>, ? super Exception> mapper) {
		return ofProviderRef().map(mapper);
	}

	/**
	 * Rewraps a value that was already derived from success's value, keeping the
	 * {@link PropertySuccess}'s origin (topProperties/properties/key/rawValue/kind)
	 * intact - useful for a conversion step that cannot itself throw, so
	 * {@link Result#map(PropertyFunction)} would be overkill, but that still needs to
	 * keep the result's origin intact for a later conversion step's error message.
	 * @param <T> success's value type.
	 * @param <U> new value type.
	 * @param success success to take the origin from.
	 * @param value already-converted value to wrap.
	 * @return new success of the same shape as success, wrapping value.
	 */
	static <T, U> Result.Success<U> mapValue(Result.Success<T> success, U value) {
		return switch (success) {
			case PropertySuccess<T> ps ->
				new PropertySuccess<>(ps.topProperties(), ps.properties(), ps.key(), ps.rawValue(), ps.kind(), value);
		};
	}

	/**
	 * Builds a rich error from a failed conversion of a previously successful result -
	 * package-visible (rather than private) only so the top-level {@link PropertySuccess}
	 * can call it from {@link Result#map(PropertyFunction)}.
	 * @param <U> the converted value type.
	 * @param previousResult the success being converted.
	 * @param e the conversion failure.
	 * @return a rich error result.
	 */
	static <U> Result.Error<U> richError(Result.Success<?> previousResult, Exception e) {
		return richError(previousResult, e, "Error converting property");
	}

	/**
	 * Like {@link #richError(Result.Success, Exception)} but for a property whose value
	 * was used to invoke {@link LogProvider#provide(String, LogConfig)} rather than a
	 * plain same-property type conversion - see
	 * {@link #provideValue(Result, String, LogConfig)}. The provider constructs a
	 * genuinely separate component with its own property tree, so a failure inside it
	 * (typically already a complete, self-describing {@link ValidationException} of its
	 * own) must not be labeled "Error converting property": this property's own value
	 * converted just fine, it merely triggered a provider that failed on its own terms.
	 * @param <U> the provided component type.
	 * @param previousResult the success whose value was provided from.
	 * @param e the provisioning failure.
	 * @return a rich error result.
	 */
	static <U> Result.Error<U> providingError(Result.Success<?> previousResult, Exception e) {
		return richError(previousResult, e, "Failure providing from property");
	}

	/**
	 * Like {@link Result#map(PropertyFunction)} but specifically for the "resolve to a
	 * {@link LogProvider}, then {@link LogProvider#provide(String, LogConfig) provide}"
	 * step used by output/encoder/appender resolution. A plain {@link Result#map} would
	 * relabel any failure from {@code provide(...)} as "Error converting property" even
	 * though this property's own value converted to a provider just fine - see
	 * {@link #providingError(Result.Success, Exception)}.
	 * @param <T> component type.
	 * @param providerResult an already-resolved provider, e.g. from
	 * {@link LogProperty#ofProvider(PropertyFunction)}.
	 * @param name component name passed to
	 * {@link LogProvider#provide(String, LogConfig)}.
	 * @param config config passed to {@link LogProvider#provide(String, LogConfig)}.
	 * @return the provided component, or the same {@link Result.Missing}/
	 * {@link Result.Error} a plain conversion would have produced.
	 */
	static <T> Result<T> provideValue(Result<LogProvider<T>> providerResult, String name, LogConfig config) {
		return switch (providerResult) {
			case Result.Success<LogProvider<T>> s -> {
				try {
					yield mapValue(s, s.value().provide(name, config));
				}
				catch (Exception e) {
					yield providingError(s, e);
				}
			}
			case Result.Missing<LogProvider<T>> m -> m.convert();
			case Result.Error<LogProvider<T>> e -> e.convert();
		};
	}

	private static <U> Result.Error<U> richError(Result.Success<?> previousResult, Exception e, String chainedLabel) {
		String fqk = previousResult.key();
		PropertySuccess<?> ps = switch (previousResult) {
			case PropertySuccess<?> p -> p;
		};
		if (ps.kind() == PropertySuccess.Kind.VALUE) {
			String resolvedKey = ps.topProperties().description(fqk);
			String message = "Error for property. key: " + resolvedKey + ", " + e.getMessage();
			return new Result.Error<>(resolvedKey, message, e);
		}
		var badProps = ps.properties();
		String badDescription = badProps.description(fqk);
		String resolvedKey = "'" + fqk + "' from " + badDescription;
		boolean chained = e instanceof PropertyConvertException || e instanceof ValidationException;
		String own;
		if (chained) {
			own = chainedLabel + ". key: " + resolvedKey + ", value: '" + ps.valueDescription() + "'";
		}
		else {
			String exceptionPrefix = e.getClass() == IllegalArgumentException.class ? "" : errorName(e) + " ";
			own = "Error for property. key: " + resolvedKey + ", " + exceptionPrefix + e.getMessage();
		}
		/*
		 * Only worth a "Tried:" line if topProperties actually searched more broadly than
		 * where the value was found (a chained/composite LogProperties) - for a
		 * non-chained property, topProperties().description(fqk) is identical to
		 * badDescription above, so appending it would just repeat the "key: ... from X"
		 * text already shown on this exact message with nothing new to say.
		 */
		String triedDescription = ps.topProperties().description(fqk);
		if (!triedDescription.equals(badDescription)) {
			String tried = "Tried: '" + fqk + "' from " + triedDescription;
			if (tried.length() > 120) {
				var sources = new ArrayList<String>();
				ps.topProperties().visit(fqk, (source, sourceKey) -> {
					sources.add(source.description(sourceKey));
					return null;
				});
				tried = "Tried:\n    '" + fqk + "' from:\n        " + String.join(",\n        ", sources);
			}
			own += "\n" + tried;
		}
		/*
		 * Root cause first: e's own message already has everything beneath this layer
		 * fully rendered (its own recursive "↳" trail included, if any), so this layer's
		 * contribution is appended as one more arrow line rather than prepended - keeps
		 * the deepest, most specific failure at the top no matter how many layers wrap
		 * it. A non-chained e (a plain library exception like NumberFormatException) has
		 * nothing beneath it to defer to, so its own message stays the whole (root) text.
		 */
		String message = chained ? e.getMessage() + "\n  ↳ " + own.replace("\n", "\n    ") : own;
		return new Result.Error<>(resolvedKey, message, e);
	}

	/**
	 * Prefers {@link PropertyProblem#errorName()} (a short, stable name) over the
	 * exception's actual class name - a foreign exception (not one of ours) just gets its
	 * full class name as before.
	 */
	private static String errorName(Exception e) {
		if (e instanceof PropertyProblem pp) {
			return pp.errorName();
		}
		return e.getClass().getName();
	}

	/**
	 * Checks that value is not null, throwing {@link PropertyMissingException} naming key
	 * if it is. Useful for a property that is required but was set programmatically (e.g.
	 * a builder setter) rather than resolved through {@link #ofString()}/etc, so there is
	 * no {@link Result} to check for missing-ness.
	 * @param <T> value type.
	 * @param key property key, used only in the exception message.
	 * @param value value to check.
	 * @return value, never null.
	 * @throws PropertyMissingException if value is null.
	 */
	static <T> T require(String key, @Nullable T value) {
		if (value == null) {
			throw new PropertyMissingException("Value is required not null. property key='" + key + "'");
		}
		return value;
	}

	/**
	 * Adds a fallback key to try (without <code>{name}</code> interpolation) if none of
	 * the keys tried so far are found. Keys already added are tried first, in order; this
	 * one is tried last.
	 * @param fallbackKey additional key to try.
	 * @return a new LogProperty trying this key's keys followed by fallbackKey.
	 */
	default LogProperty or(String fallbackKey) {
		validateKey(fallbackKey);
		return withKey(fallbackKey);
	}

	/**
	 * Adds a fallback key to try (with <code>{name}</code> interpolated to nameParam) if
	 * none of the keys tried so far are found.
	 * @param fallbackKey additional key to try, containing a <code>{name}</code>
	 * placeholder.
	 * @param nameParam value to interpolate for <code>{name}</code>.
	 * @return a new LogProperty trying this key's keys followed by fallbackKey.
	 */
	default LogProperty or(String fallbackKey, String nameParam) {
		String interpolated = LogProperties.interpolateNamedKey(fallbackKey, nameParam);
		validateKey(interpolated);
		return withKey(interpolated);
	}

	private LogProperty withKey(String fallbackKey) {
		List<String> combined = new ArrayList<>(keys());
		combined.add(fallbackKey);
		return new DefaultLogProperty(List.copyOf(combined), properties());
	}

	/**
	 * Creates a LogProperty for a single key.
	 * @param properties properties this key will be looked up against.
	 * @param key key.
	 * @return LogProperty.
	 */
	static LogProperty of(LogProperties properties, String key) {
		validateKey(key);
		return new DefaultLogProperty(List.of(key), properties);
	}

	/**
	 * Creates a LogProperty for a single key with a <code>{name}</code> parameter
	 * interpolated.
	 * @param properties properties this key will be looked up against.
	 * @param key key, containing a <code>{name}</code> placeholder.
	 * @param nameParam value to interpolate for <code>{name}</code>.
	 * @return LogProperty.
	 */
	static LogProperty of(LogProperties properties, String key, String nameParam) {
		String interpolated = LogProperties.interpolateNamedKey(key, nameParam);
		validateKey(interpolated);
		return new DefaultLogProperty(List.of(interpolated), properties);
	}

	/**
	 * Validates a fully resolved (post <code>{name}</code> interpolation) property key -
	 * must start with {@value LogProperties#ROOT_PREFIX}, must not start or end with
	 * {@value LogProperties#SEP}, and must not use a reserved key parameter name.
	 * @param key fully resolved key to validate.
	 * @throws IllegalArgumentException if key is malformed.
	 */
	private static void validateKey(String key) {
		if (!key.startsWith(LogProperties.ROOT_PREFIX)) {
			throw new IllegalArgumentException(
					"Property key should start with: '" + LogProperties.ROOT_PREFIX + "'. key = " + key);
		}
		if (key.endsWith(LogProperties.SEP) || key.startsWith(LogProperties.SEP)) {
			throw new IllegalArgumentException("Property key should not start or end with '" + LogProperties.SEP + "'");
		}
		LogProperties.validateKeyParameters(key, Set.of());
	}

	/**
	 * An error friendly {@link Function} for converting properties.
	 *
	 * @param <T> input type.
	 * @param <R> output type.
	 * @param <E> error type.
	 */
	public interface PropertyFunction<T extends @Nullable Object, R extends @Nullable Object, E extends Exception>
			extends Function<T, R> {

		@Override
		default R apply(T t) {
			try {
				return _apply(t);
			}
			catch (Exception e) {
				sneakyThrow(e);
				throw new RuntimeException(e);
			}
		}

		/**
		 * Apply that throws error.
		 * @param t input
		 * @return output
		 * @throws E if an error happened in function.
		 */
		public R _apply(T t) throws E;

		@SuppressWarnings("unchecked")
		private static <E extends Throwable> void sneakyThrow(final Throwable x) throws E {
			throw (E) x;
		}

	}

	/**
	 * Parent interface for property exceptions.
	 */
	sealed interface PropertyProblem permits PropertyConvertException, PropertyMissingException, ValidationException,
			LogProviderRef.NotFoundException {

		/**
		 * A short, stable name for this problem used in error messages instead of the
		 * exception's full (and for a nested class, {@code $}-separated) class name.
		 * Deliberately hardcoded per implementation rather than derived via
		 * {@code getClass().getSimpleName()} so it stays stable across renames/moves of
		 * the actual exception class.
		 * @return error name.
		 */
		String errorName();

	}

	/**
	 * Property helper mixin.
	 */
	interface PropertySupport {

		/**
		 * String key value properties.
		 * @return properties.
		 */
		public LogProperties properties();

	}

	/**
	 * Thrown if an error happens while converting a property.
	 */
	final static class PropertyConvertException extends RuntimeException implements PropertyProblem {

		private static final long serialVersionUID = -6260241455268426342L;

		/**
		 * Key.
		 */
		private final String key;

		/**
		 * Creates convert exception.
		 * @param key property key.
		 * @param message error message
		 * @param cause maybe null.
		 */
		PropertyConvertException(String key, String message, @Nullable Throwable cause) {
			super(message, cause);
			this.key = key;
		}

		/**
		 * Property key.
		 * @return key.
		 */
		public String key() {
			return this.key;
		}

		@Override
		public String errorName() {
			return "PropertyConvertException";
		}

	}

	/**
	 * Throw if property is missing.
	 */
	final static class PropertyMissingException extends NoSuchElementException implements PropertyProblem {

		private static final long serialVersionUID = 4203076848052565692L;

		/**
		 * Creates a missing property exception.
		 * @param s error message.
		 */
		PropertyMissingException(String s) {
			super(s);
		}

		@Override
		public String errorName() {
			return "PropertyMissingException";
		}

	}

	/**
	 * Thrown by {@link Validator#validate()} when one or more of the accumulated
	 * {@link Result}s failed, reporting every failure in a single message instead of just
	 * the first one encountered.
	 */
	final static class ValidationException extends RuntimeException implements PropertyProblem {

		private static final long serialVersionUID = -8416817560218813225L;

		ValidationException(String message, @Nullable Throwable cause) {
			super(message, cause);
		}

		@Override
		public String errorName() {
			return "ValidationException";
		}

		/**
		 * Validates a list of results and throws {@link ValidationException} if any
		 * results are not {@link Result.Success}.
		 * @param builder class to prefix to full message.
		 * @param results list of results.
		 * @throws ValidationException if any result in the supplied list is not
		 * successful.
		 */
		public static void validate(Class<?> builder, List<Result<?>> results) throws ValidationException {
			StringBuilder sb = new StringBuilder().append("Validation failed for ")
				.append(builder.getName())
				.append(":");
			Throwable cause = null;
			boolean failed = false;
			for (var r : results) {
				switch (r) {
					case Result.Success<?> s -> {
						continue;
					}
					case Result.Missing<?> m -> {
						sb.append("\n");
						sb.append(m.message());
						failed = true;
					}
					case Result.Error<?> e -> {
						sb.append("\n");
						sb.append(e.message());
						if (cause == null) {
							cause = e.cause();
						}
						failed = true;
					}
				}
			}
			if (failed) {
				throw new ValidationException(sb.toString(), cause);
			}

		}

		/**
		 * Wraps cause in a {@link ValidationException}, using the same "Validation failed
		 * for &lt;class&gt;:" message prefix {@link #validate(Class, List)} itself uses.
		 * {@link ValidationException}'s constructor is package-private, so this is the
		 * supported way for code outside this package - generated builder code in
		 * particular - to report a downstream factory method's own defensive/cross-field
		 * check (an {@link IllegalArgumentException}, say) through the same channel as a
		 * per-field validation failure, without duplicating the message format itself.
		 * Returns rather than throws so the caller writes
		 * {@code throw ValidationException.of(...)} - keeps the throw itself, and
		 * therefore unreachable-code/definite-assignment analysis at the call site,
		 * visible there instead of hidden inside this method.
		 * @param builder class to prefix to the message, same role as
		 * {@link #validate(Class, List)}'s own parameter.
		 * @param cause the exception to wrap.
		 * @return a new {@link ValidationException} wrapping cause; not thrown by this
		 * method.
		 */
		public static ValidationException of(Class<?> builder, RuntimeException cause) {
			return new ValidationException("Validation failed for " + builder.getName() + ": " + cause.getMessage(),
					cause);
		}

	}

	/**
	 * Accumulates {@link Result}s from one or more property lookups and validates them
	 * together, so that a caller resolving several properties in sequence can report
	 * every failure at once instead of failing fast on the first bad property.
	 * <p>
	 * This is the mechanism generated {@code fromProperties(LogProperties)} builder
	 * implementations use: each property is looked up and its {@link Result} is added to
	 * the validator as it is read; {@link #validate()} is then called once at the end,
	 * after all properties have been read, so a user who misconfigured several properties
	 * sees all of the problems together rather than fixing them one at a time across
	 * repeated runs.
	 * <p>
	 * Use {@link #add(Result)} for a property that is required: a {@link Result.Missing}
	 * is treated as a failure. Use {@link #addIfError(Result)} for an optional property
	 * that already has a fallback applied (typically via {@link Result#or(Object)} with
	 * the builder's current field value): a {@link Result.Missing} is expected in that
	 * case and is silently ignored, but a {@link Result.Error} (the property was present
	 * but failed to convert) is still a real problem and is reported.
	 *
	 * @apiNote a {@code Validator} is single-use: create one with {@link #of(Class)}, add
	 * every result that should be checked, and call {@link #validate()} exactly once at
	 * the end.
	 */
	public static class Validator {

		private final List<Result<?>> results = new ArrayList<>();

		private final Class<?> builder;

		/**
		 * Creates a validator whose {@link ValidationException} messages will be prefixed
		 * with the name of the given class.
		 * @param builder the class on whose behalf properties are being resolved -
		 * typically the builder calling this method. It is only used to identify the
		 * source of a failure in the exception message; no state is read from it.
		 * @return newly created validator.
		 */
		public static Validator of(Class<?> builder) {
			return new Validator(builder);
		}

		private Validator(Class<?> builder) {
			this.builder = builder;
		}

		/**
		 * Adds a result that must be present. Both {@link Result.Missing} and
		 * {@link Result.Error} will cause {@link #validate()} to fail. Use this for a
		 * required property; for an optional property that already has a fallback value
		 * applied, use {@link #addIfError(Result)} instead so a missing value does not
		 * itself trigger a failure.
		 * @param result result to check.
		 * @return this.
		 */
		public Validator add(Result<?> result) {
			results.add(result);
			return this;
		}

		/**
		 * Adds a result only if it is a {@link Result.Error}; a {@link Result.Missing}
		 * result is silently ignored. Intended for an optional property that has already
		 * had a fallback value applied (for example via {@link Result#or(Object)}): the
		 * property being absent is expected and fine, but if it was present and failed to
		 * convert that should not be swallowed.
		 * @param result result to check.
		 * @return this.
		 */
		public Validator addIfError(Result<?> result) {
			if (result instanceof Result.Error<?> e) {
				results.add(e);
			}
			return this;
		}

		/**
		 * Validates every result added so far via {@link #add(Result)} or
		 * {@link #addIfError(Result)}, throwing a single exception listing all failures
		 * if one or more of them are not {@link Result.Success}.
		 * @throws ValidationException if any added result is a {@link Result.Missing}
		 * (added via {@link #add(Result)}) or a {@link Result.Error} (added via either
		 * method).
		 */
		public void validate() throws ValidationException {
			ValidationException.validate(builder, results);
		}

		/**
		 * Validates every name in a list resolved from a comma-separated list property
		 * (e.g. appenders/routes) before any of those names are used to construct
		 * anything, so a bad name is reported against the list property itself rather
		 * than against whatever unrelated {@code {name}}-keyed property a downstream
		 * lookup happens to interpolate first. Static (rather than an instance method
		 * using an existing {@link Validator}'s own {@code builder} field) since this
		 * check is independent of - and never participates in - whatever else a
		 * particular {@link Validator} instance may have accumulated via
		 * {@link #add(Result)}/{@link #addIfError(Result)}.
		 * @param component the class on whose behalf these names are being resolved -
		 * used to prefix the thrown {@link ValidationException}'s message, the same role
		 * {@link #of(Class)} plays elsewhere.
		 * @param kind short noun describing what these names are (e.g.
		 * {@code "appender"}, {@code "route"}), used only for the message.
		 * @param source the result the list itself was resolved from, used only for its
		 * {@link Result#describe()} in the message.
		 * @param names names to validate.
		 * @return names unchanged if all are valid.
		 * @throws ValidationException if any name is blank or has other characters.
		 */
		static List<String> validateNames(Class<?> component, String kind, Result<?> source, List<String> names) {
			for (String name : names) {
				if (!isValidName(name)) {
					throw ValidationException.of(component,
							new IllegalArgumentException("Invalid " + kind + " name '" + name + "' in "
									+ source.describe() + ": must be alphanumeric (hyphen/underscore allowed)"));
				}
			}
			return names;
		}

		/**
		 * Validates that a key parameter token (the identifier inside <code>{...}</code>
		 * in a key template, e.g. <code>"name"</code> in <code>a.{name}.b</code>) is well
		 * formed: non-blank and made up only of letters, digits, <code>-</code>, or
		 * <code>_</code>. This guards against a malformed key <em>template</em>, not user
		 * input.
		 * @param parameter parameter token found in a key template.
		 * @return the parameter unchanged if valid.
		 * @throws IllegalArgumentException if the parameter is blank or has other
		 * characters.
		 */
		static String validateKeyParameter(String parameter) {
			if (!isValidName(parameter)) {
				throw new IllegalArgumentException(
						"Key parameter must be alphanumeric (hyphen/underscore allowed). parameter: '" + parameter
								+ "'");
			}
			return parameter;
		}

		/**
		 * Validates that a value about to be interpolated for a <code>{name}</code>-style
		 * key parameter is well formed: non-blank and made up only of letters, digits,
		 * <code>-</code>, or <code>_</code>. This is the single choke point every
		 * keyed-name mechanism in the project (appender/output/encoder/publisher/route
		 * names, and every annotation-processor generated builder's own property prefix)
		 * funnels through via {@link LogProperties#interpolateKey(String, Function)}.
		 * @param key original, pre-interpolation key template (e.g.
		 * <code>a.{name}.b</code>).
		 * @param parameter name of the parameter being interpolated (e.g.
		 * <code>"name"</code>).
		 * @param value value about to be substituted for the parameter.
		 * @return the value unchanged if valid.
		 * @throws IllegalArgumentException if the value is blank or has other characters.
		 */
		static String validateKeyParameterValue(String key, String parameter, String value) {
			validateKeyParameter(parameter);
			if (!isValidName(value)) {
				throw new IllegalArgumentException("\"" + key + "\" cannot be interpolated: parameter '" + parameter
						+ "' value '" + value + "' must be alphanumeric (hyphen/underscore allowed)");
			}
			return value;
		}

		private static boolean isValidName(String s) {
			if (s.isBlank()) {
				return false;
			}
			for (int i = 0; i < s.length(); i++) {
				char c = s.charAt(i);
				if (!Character.isLetterOrDigit(c) && c != '-' && c != '_') {
					return false;
				}
			}
			return true;
		}

	}

	/**
	 * The result of a property fetched from properties.
	 *
	 * @param <T> property type.
	 */
	sealed interface Result<T> {

		/**
		 * Registers this result with {@code validator} via {@link Validator#add(Result)}
		 * (a still-{@link Missing} result is treated as a real failure) and returns this
		 * result unchanged, so a chain that builds a required property can register
		 * itself with a shared {@link Validator} and keep going in one expression -
		 * {@code properties.forKey(key).ofString().or(fallback).validate(v)} - instead of
		 * a separate {@code var x = ...; v.add(x);} statement pair. The validator is
		 * shared and single-use by design: add every property's result this way (or via
		 * {@link #validateIfError(Validator)} for an optional one), then call
		 * {@link Validator#validate()} exactly once at the end to report every failure
		 * together instead of one exception per property.
		 * @param validator validator to add this result to.
		 * @return this result, unchanged.
		 */
		default ValidatedResult<T> validate(Validator validator) {
			validator.add(this);
			return (ValidatedResult<T>) this;
		}

		/**
		 * Like {@link #validate(Validator)} but via {@link Validator#addIfError(Result)}
		 * instead of {@link Validator#add(Result)} - a {@link Missing} result is expected
		 * and ignored, only a {@link Result.Error} is treated as a failure. Intended for
		 * an optional property that already has a fallback applied (for example via
		 * {@link #or(Object)}): the property being absent is fine, but if it was present
		 * and failed to convert that should not be swallowed.
		 * @param validator validator to add this result to.
		 * @return this result, unchanged.
		 */
		default ValidatedResult<T> validateIfError(Validator validator) {
			validator.addIfError(this);
			return (ValidatedResult<T>) this;
		}

		/**
		 * Convenience for a genuine one-off, single-property validation that does not
		 * need to participate in a larger batch: builds a single-use {@link Validator},
		 * registers this result with it via {@link Validator#add(Result)}, and validates
		 * immediately - instead of registering with a caller-managed {@link Validator}
		 * via {@link #validate(Validator)} and deferring to a later
		 * {@link Validator#validate()} call.
		 * <p>
		 * Uses {@link Validator#add(Result)} semantics: a still-{@link Missing} result is
		 * treated as a failure, same as an absent required property with no fallback. If
		 * this property is optional, apply a fallback first -
		 * {@code properties.forKey(key).ofString().or(fallback).validateNow(component)} -
		 * since a result with a fallback applied is never {@link Missing}. For a property
		 * that should tolerate being missing but not being present-and-malformed, use
		 * {@link #validateIfError(Validator)} with an externally-owned {@link Validator}
		 * instead - there is deliberately no addIfError-equivalent of this method, to
		 * avoid two same-named single-property convenience methods with silently
		 * different failure semantics.
		 * @param component the class on whose behalf this property is being resolved -
		 * only used to name the source of the failure in the exception message.
		 * @return value.
		 * @throws ValidationException if this result is not a {@link Success}.
		 */
		default T validateNow(Class<?> component) {
			var v = Validator.of(component);
			v.add(this);
			v.validate();
			var result = switch (this) {
				case ValidatedResult<T> vr -> vr;
			};
			return result.value();
		}

		/**
		 * Returns the current result if fallback is null or returns fallback as a result
		 * if this result is missing.
		 * @param fallback maybe <code>null</code>.
		 * @return value.
		 */
		public Result<T> or(@Nullable T fallback);

		/**
		 * Returns the current result if success or error otherwise fallback supplier is
		 * used. If the supplier returns <code>null</code> then the result will be mising.
		 * @param fallback may return <code>null</code> but not recommended.
		 * @return value.
		 */
		public Result<T> or(Supplier<T> fallback);

		/**
		 * Maps this result's value through {@code mapper}. On success, keeps the origin
		 * ({@link PropertySuccess#topProperties() topProperties}/properties/key/
		 * rawValue/kind, see {@link PropertySuccess}) so a later conversion step's error
		 * message can still point back to it. On failure, builds a rich error message -
		 * which key it came from, where that key was found, and (for
		 * {@link PropertyConvertException}/{@link ValidationException} causes) the
		 * original raw value - with a "Tried:" line searching {@code topProperties}: the
		 * {@link LogProperties} the very first {@link LogProperties#forKey(String)} in
		 * this chain was looked up against, which for a chained/composite
		 * {@code LogProperties.of(a, b)} can be broader than the exact source the value
		 * was actually found at. Usable at any point in a chain, not just directly off a
		 * {@link LogProperty}, since a {@link Success} keeps pointing back to the
		 * property it originally came from no matter how many conversions have run since.
		 * @param <U> result type
		 * @param mapper mapping function.
		 * @return mapped result.
		 */
		public <U> Result<U> map(PropertyFunction<T, U, ? super Exception> mapper);

		/**
		 * A description of the result for error messages.
		 * @return description.
		 */
		public String describe();

		/**
		 * A property that is present.
		 *
		 * @param <T> property type.
		 */
		public sealed interface Success<T> extends RequiredResult<T> permits PropertySuccess {

			@Override
			default @Nullable T valueOrNull() {
				return value();
			}

			/**
			 * Gets the value and will never fail for Success.
			 * @return value.
			 */
			@Override
			public T value();

			@Override
			default Success<T> or(@Nullable T fallback) {
				return this;
			}

			@Override
			default Success<T> or(Supplier<T> fallback) {
				return this;
			}

			/**
			 * Original property key where the value was derived from or was passed a
			 * fallback.
			 * @return key.
			 */
			public String key();

		}

		/**
		 * A property that is missing (<code>null</code>).
		 *
		 * @param <T> property type.
		 * @param properties the properties that were searched, kept around so
		 * {@link #or(Object)} can hand it to the {@link PropertySuccess} it builds for a
		 * supplied fallback.
		 * @param keys keys.
		 * @param message description of where the property is missing.
		 */
		public record Missing<T>(LogProperties properties, List<String> keys,
				String message) implements ValidatedResult<T> {
			/**
			 * A property that is missing (<code>null</code>).
			 * @param properties the properties that were searched.
			 * @param keys keys.
			 * @param message description of where the property is missing.
			 */
			public Missing {
				if (keys.isEmpty()) {
					throw new IllegalArgumentException("one key is required");
				}
			}

			@Override
			public @Nullable T valueOrNull() {
				return null;
			}

			@Override
			public T value() throws NoSuchElementException {
				throw new PropertyMissingException(message);
			}

			@Override
			public Result<T> or(@Nullable T fallback) {
				if (fallback != null) {
					return new PropertySuccess<>(properties, properties, keys.get(0), fallback,
							PropertySuccess.Kind.VALUE, fallback);
				}
				return this;
			}

			@Override
			public Result<T> or(Supplier<T> fallback) {
				return or(fallback.get());
			}

			/**
			 * Helper just to cast the result to a different parameterized type.
			 * @param <R> parameterized type.
			 * @return this.
			 */
			@SuppressWarnings("unchecked")
			public <R> Missing<R> convert() {
				return (Missing<R>) this;
			}

			@Override
			public <U> Missing<U> map(PropertyFunction<T, U, ? super Exception> mapper) {
				return convert();
			}

			@Override
			public String describe() {
				return "Missing[" + keys + "]";
			}

		}

		/**
		 * A property that was present but failed conversion.
		 *
		 * @param <T> property type.
		 * @param key property key that failed to convert.
		 * @param message failure message.
		 * @param cause exception thrown while trying to convert.
		 */
		@SuppressWarnings("JavaLangClash")
		public record Error<T>(String key, String message, Exception cause) implements ValidatedResult<T> {
			/*
			 * TODO consider rename to Failure
			 */
			@Override
			public @Nullable T valueOrNull() {
				throw new PropertyConvertException(key, message, cause);
			}

			@Override
			public T value() throws PropertyConvertException {
				throw new PropertyConvertException(key, message, cause);
			}

			@Override
			public Error<T> or(@Nullable T fallback) {
				return this;
			}

			@Override
			public Error<T> or(Supplier<T> fallback) {
				return this;
			}

			/**
			 * Helper just to cast the result to a different parameterized type.
			 * @param <R> parameterized type.
			 * @return this.
			 */
			@SuppressWarnings("unchecked")
			public <R> Error<R> convert() {
				return (Error<R>) this;
			}

			@Override
			public <U> Error<U> map(PropertyFunction<T, U, ? super Exception> mapper) {
				return convert();
			}

			@Override
			public String describe() {
				return "Error[" + key + "](" + message + ")";
			}
		}

	}

	/**
	 * A {@link Result} that is safe to pull a value out of - either because it can never
	 * actually be missing ({@link RequiredResult}: a {@link Success} or an
	 * {@link Result.Error}, both already resolved one way or the other) or because a
	 * caller has explicitly acknowledged the possibility of a still-{@link Missing}
	 * result by running it through {@link Result#validate(Validator)},
	 * {@link Result#validateIfError(Validator)}, or {@link Result#validateNow(Class)}.
	 * <p>
	 * {@link Result} itself deliberately does not declare {@link #value()}/
	 * {@link #valueOrNull()}/{@link #optional()} - a bare {@link Result} returned by
	 * {@link LogProperty#ofString()} and friends forces a caller through one of those
	 * validation paths before it can pull a value out, instead of blindly calling
	 * {@code value()} on a result that might still be {@link Missing}.
	 *
	 * @param <T> property type.
	 */
	sealed interface ValidatedResult<T> extends Result<T> {

		/**
		 * Gets the value and will fail with {@link NoSuchElementException} if there is no
		 * value.
		 * @return value.
		 * @throws PropertyMissingException if there is no value.
		 * @throws PropertyConvertException if the property failed conversion.
		 */
		public T value() throws PropertyMissingException, PropertyConvertException;

		/**
		 * Gets the value.
		 * @return value or <code>null</code>.
		 */
		public @Nullable T valueOrNull();

		/**
		 * Gets a value if there is if not uses the fallback.
		 * @param fallback maybe <code>null</code>.
		 * @return value.
		 */
		default @Nullable T valueOrNull(@Nullable T fallback) {
			var v = valueOrNull();
			if (v != null) {
				return v;
			}
			return fallback;
		}

		/**
		 * Convenience that turns a value into an optional.
		 * @return optional.
		 */
		default Optional<T> optional() {
			return Optional.ofNullable(valueOrNull());
		}

	}

	/**
	 * A result that is not missing and will either be an error or success.
	 *
	 * @param <T> property type.
	 */
	sealed interface RequiredResult<T> extends ValidatedResult<T> {

	}

}

/**
 * A property that is present, either because it was actually found in properties, or
 * (when {@code kind} is {@link Kind#VALUE}) because it was missing there and a fallback
 * value was supplied instead.
 * <p>
 * Not public: nothing outside {@code LogProperty}'s own resolution machinery (in this
 * package) constructs or pattern-matches on this directly - callers work through the
 * sealed {@link LogProperty.Result}/{@link LogProperty.Result.Success} interfaces
 * instead.
 *
 * @param <T> property type.
 * @param topProperties the {@link LogProperties} the very first
 * {@link LogProperties#forKey(String)} in this chain was looked up against - for a
 * chained/composite {@code LogProperties.of(a, b)} this is the whole composite, not just
 * whichever member the value ended up found in. Used for a failure message's "Tried:"
 * line, which can therefore search more broadly than {@code properties}.
 * @param properties the properties searched; for {@link Kind#STRING}/{@link Kind#LIST}/
 * {@link Kind#MAP} the <strong>exact</strong> properties where the value was found - for
 * a chained/composite search this can be narrower than {@code topProperties}, naming just
 * the one member the value was actually found in. Used for a failure message's "key: ...
 * from X" line.
 * @param key property key.
 * @param rawValue value as originally found (or the fallback, for {@link Kind#VALUE}),
 * before any conversion - a {@link String}, {@link List List&lt;String&gt;}, or
 * {@link Map Map&lt;String,String&gt;} depending on kind.
 * @param kind which of {@link LogProperty}'s {@code ofString()}/{@code ofList()}/
 * {@code ofMap()} this property was found through, and so rawValue's runtime type - or
 * {@link Kind#VALUE} if it was not found at all and rawValue is a fallback.
 * @param value actual (possibly converted) value.
 * @apiNote rawValue/kind stick around after a conversion (e.g.
 * {@link LogProperty#ofInt()} converting the string this was found as into an
 * {@code Integer}) so error messages can still describe the original value - value's type
 * and rawValue's kind are not always in sync.
 */
record PropertySuccess<T>(LogProperties topProperties, LogProperties properties, String key, Object rawValue,
		PropertySuccess.Kind kind, T value) implements LogProperty.Result.Success<T> {

	/**
	 * Which of {@link LogProperty}'s typed accessors a {@link PropertySuccess} was found
	 * through, and so what {@link PropertySuccess#rawValue()}'s runtime type actually is
	 * - or that it was not found at all and is a plain fallback value.
	 */
	enum Kind {

		/**
		 * rawValue is a {@link String}, found via {@link LogProperty#ofString()}.
		 */
		STRING,
		/**
		 * rawValue is a {@link List List&lt;String&gt;}, found via
		 * {@link LogProperty#ofList()}.
		 */
		LIST,
		/**
		 * rawValue is a {@link Map Map&lt;String,String&gt;}, found via
		 * {@link LogProperty#ofMap()}.
		 */
		MAP,
		/**
		 * Not found in properties at all - rawValue is a plain fallback value supplied
		 * via {@link LogProperty.Result#or}.
		 */
		VALUE

	}

	/**
	 * Successfully found property value.
	 * @param topProperties the properties the original lookup started from.
	 * @param properties the properties searched.
	 * @param key property key.
	 * @param rawValue value as originally found (or the fallback), before any conversion.
	 * @param kind rawValue's kind.
	 * @param value actual value should not be <code>null</code>.
	 */
	PropertySuccess {
		if (value == null) {
			throw new NullPointerException("value");
		}
	}

	@Override
	public <U> LogProperty.Result<U> map(LogProperty.PropertyFunction<T, U, ? super Exception> mapper) {
		try {
			U u = mapper._apply(value);
			return new PropertySuccess<>(topProperties, properties, key, rawValue, kind, u);
		}
		catch (Exception e) {
			return LogProperty.richError(this, e);
		}
	}

	@Override
	public String describe() {
		if (kind == Kind.VALUE) {
			return "Fallback[" + key + "]=" + LogRedactor.redactProperty(key, String.valueOf(value));
		}
		return "Property[" + key + "]=" + valueDescription();
	}

	String valueDescription() {
		String s = kind == Kind.STRING ? (String) rawValue : String.valueOf(rawValue);
		return LogRedactor.redactProperty(key, s);
	}

}

final class LogRedactor {

	private LogRedactor() {
	}

	private static final Set<String> REDACTED_KEYS = Set.of("password", "apikey", "secret", "token", "passwd",
			"passphrase", "credential", "privatekey", "accesskey", "authorization");

	private static final String REDACTED_VALUE = "<REDACTED>";

	static String redactProperty(String key, String input) {
		if (sensitiveKey(key)) {
			return REDACTED_VALUE;
		}
		if (input.indexOf('?') < 0 && input.indexOf('@') < 0) {
			return input;
		}
		try {
			return redactUri(new URI(input));
		}
		catch (URISyntaxException e) {
			return input;
		}
	}

	private static boolean sensitiveKey(String key) {
		String normalizedKey = key.toLowerCase(Locale.ROOT).replace(".", "").replace("_", "").replace("-", "");
		for (var k : REDACTED_KEYS) {
			if (normalizedKey.contains(k)) {
				return true;
			}
		}
		return false;
	}

	static String redactUri(URI uri) {
		String source = uri.toString();
		var result = new StringBuilder(source);
		String query = uri.getRawQuery();
		if (query != null) {
			int queryStart = source.indexOf('?') + 1;
			result.replace(queryStart, queryStart + query.length(), redactQuery(query));
		}
		String authority = uri.getRawAuthority();
		if (authority != null) {
			int userInfoEnd = authority.lastIndexOf('@');
			if (userInfoEnd >= 0) {
				String scheme = uri.getScheme();
				int authorityStart = scheme == null ? 2 : scheme.length() + 3;
				result.replace(authorityStart, authorityStart + userInfoEnd, REDACTED_VALUE);
			}
		}
		return result.toString();
	}

	private static String redactQuery(String query) {
		var result = new StringBuilder();
		int start = 0;
		for (int i = 0; i <= query.length(); i++) {
			if (i == query.length() || query.charAt(i) == '&' || query.charAt(i) == ',') {
				String pair = query.substring(start, i);
				int equals = pair.indexOf('=');
				if (equals >= 0) {
					String key = pair.substring(0, equals);
					String decodedKey = PercentCodec.decode(key, StandardCharsets.UTF_8);
					result.append(key)
						.append('=')
						.append(sensitiveKey(decodedKey) ? REDACTED_VALUE : pair.substring(equals + 1));
				}
				else {
					result.append(pair);
				}
				if (i < query.length()) {
					result.append(query.charAt(i));
				}
				start = i + 1;
			}
		}
		return result.toString();
	}

}

final class DefaultLogProperty implements LogProperty {

	private final List<String> keys;

	private final LogProperties properties;

	DefaultLogProperty(List<String> keys, LogProperties properties) {
		super();
		if (keys.isEmpty()) {
			throw new IllegalArgumentException("at least one key is required");
		}
		this.keys = keys;
		this.properties = properties;
	}

	@Override
	public String key() {
		return keys.get(0);
	}

	@Override
	public List<String> keys() {
		return keys;
	}

	@Override
	public LogProperties properties() {
		return this.properties;
	}

	@Override
	public Result<String> ofString() {
		return resolve(k -> properties.visit(k, (p, kk) -> {
			var v = p.valueOrNull(kk);
			return v == null ? null : new PropertySuccess<>(properties, p, kk, v, PropertySuccess.Kind.STRING, v);
		}));
	}

	@Override
	public Result<List<String>> ofList() {
		return resolve(k -> properties.visit(k, (p, kk) -> {
			var v = p.listOrNull(kk);
			return v == null ? null : new PropertySuccess<>(properties, p, kk, v, PropertySuccess.Kind.LIST, v);
		}));
	}

	@Override
	public Result<Map<String, String>> ofMap() {
		try {
			return resolve(k -> properties.visit(k, (p, kk) -> {
				Map<String, String> v;
				try {
					v = p.mapOrNull(kk);
				}
				catch (IllegalArgumentException e) {
					throw new MapFormException("'" + kk + "' from " + p.description(kk), e);
				}
				return v == null ? null : new PropertySuccess<>(properties, p, kk, v, PropertySuccess.Kind.MAP, v);
			}));
		}
		catch (MapFormException e) {
			var cause = e.cause;
			return new Result.Error<>(e.resolvedKey,
					"Error for property. key: " + e.resolvedKey + ", " + cause.getMessage(), cause);
		}
	}

	/*
	 * Carries which source rejected a map's form out of the visit, so the error names the
	 * key and source the way every other property error does.
	 */
	@SuppressWarnings("serial")
	private static final class MapFormException extends RuntimeException {

		final String resolvedKey;

		final IllegalArgumentException cause;

		MapFormException(String resolvedKey, IllegalArgumentException cause) {
			super(null, cause, false, false);
			this.resolvedKey = resolvedKey;
			this.cause = cause;
		}

	}

	private <T> Result<T> resolve(java.util.function.Function<String, Result.@Nullable Success<T>> lookup) {
		for (String k : keys) {
			var success = lookup.apply(k);
			if (success != null) {
				return success;
			}
		}
		return missingResult(properties, keys);
	}

	<T> Result.Missing<T> missingResult(LogProperties props, List<String> keys) {
		List<String> resolvedKeys = describeKeys(props, keys);
		String label = keys.size() == 1 ? "key" : "keys";
		String message = "Property missing. " + label + ": " + resolvedKeys;
		if (message.length() > 120) {
			var descriptions = new ArrayList<String>();
			for (var key : keys) {
				String fqk = fullyQualifiedKey(key);
				var sources = new ArrayList<String>();
				props.visit(fqk, (source, sourceKey) -> {
					sources.add(source.description(sourceKey));
					return null;
				});
				descriptions.add("'" + fqk + "' from:\n        " + String.join(",\n        ", sources));
			}
			message = "Property missing. " + label + ":\n    " + String.join(",\n    ", descriptions);
		}
		return new Result.Missing<>(props, keys, message);
	}

	List<String> describeKeys(LogProperties props, List<String> keys) {
		return keys.stream().<String>map(k -> {
			String fqk = fullyQualifiedKey(k);
			return "'" + fqk + "' from " + props.description(fullyQualifiedKey(k));
		}).toList();
	}

	String fullyQualifiedKey(String key) {
		return key;
	}

}