ConfigSubtype.java

package dev.orne.config.impl;

/*-
 * #%L
 * Orne Config
 * %%
 * Copyright (C) 2019 - 2025 Orne Developments
 * %%
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Lesser Public License for more details.
 * 
 * You should have received a copy of the GNU General Lesser Public
 * License along with this program.  If not, see
 * <http://www.gnu.org/licenses/lgpl-3.0.html>.
 * #L%
 */

import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Proxy;
import java.util.List;
import java.util.Objects;

import javax.validation.constraints.NotNull;

import org.apiguardian.api.API;

import dev.orne.config.Config;
import dev.orne.config.ConfigException;
import dev.orne.config.MutableConfig;
import dev.orne.config.WatchableConfig;

/**
 * Invocation handler for configuration proxies.
 * Invokes configuration subtype default methods directly on the extended
 * interface, delegating other method calls to the underlying configuration
 * instance.
 * 
 * @author <a href="https://github.com/ihernaez">(w) Iker Hernaez</a>
 * @version 1.0, 2025-08
 * @since 1.0
 */
@API(status = API.Status.INTERNAL, since = "1.0")
public class ConfigSubtype
implements InvocationHandler {

    /** The list of classes proxied to the configuration instance. */
    private static final List<Class<?>> PROXYED_TYPES = List.of(
            Config.class,
            MutableConfig.class,
            WatchableConfig.class);
    /** Cached {@code Object.equals()} for performance optimization. */
    private static final Method OBJECT_EQUALS;
    static {
        try {
            OBJECT_EQUALS = Object.class.getMethod(
                    "equals",
                    Object.class);
        } catch (final NoSuchMethodException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    /** The configuration instance. */
    private final @NotNull Config instance;
    /** The type of the extended configuration interface. */
    private final @NotNull Class<? extends Config> extendedType;
    /** The method handles lookup. */
    private final @NotNull MethodHandles.Lookup lookup;

    /**
     * Creates a new instance.
     *
     * @param instance The configuration instance to be proxied.
     * @param extendedType The type of extended configuration interface.
     */
    protected ConfigSubtype(
            final @NotNull Config instance,
            final @NotNull Class<? extends Config> extendedType) {
        this.instance = instance;
        this.extendedType = extendedType;
        MethodHandles.Lookup lookupInstance;
        try {
            lookupInstance = MethodHandles.privateLookupIn(extendedType, MethodHandles.lookup());
        } catch (final IllegalAccessException e) {
            lookupInstance = MethodHandles.lookup();
        }
        this.lookup = lookupInstance;
    }

    /**
     * Creates a new configuration proxy instance with the specified configuration.
     *
     * @param <T> The type of the configuration interface.
     * @param config The proxied configuration instance.
     * @param type The configuration type interface to create a proxy for.
     * @return A new configuration proxy instance.
     */
    public static <T extends Config> @NotNull T create(
            final @NotNull Config config,
            final @NotNull Class<T> type) {
        return create(
                type.getClassLoader(),
                config,
                type);
    }

    /**
     * Creates a new configuration proxy instance with the specified configuration.
     *
     * @param <T> The type of the configuration interface.
     * @param classLoader The class loader to be used for the proxy.
     * @param config The proxied configuration instance.
     * @param type The configuration type interface to create a proxy for.
     * @return A new configuration proxy instance.
     */
    public static <T extends Config> @NotNull T create(
            final @NotNull ClassLoader classLoader,
            final @NotNull Config config,
            final @NotNull Class<T> type) {
        Objects.requireNonNull(classLoader, "The class loader must not be null");
        Objects.requireNonNull(config, "The configuration instance must not be null");
        Objects.requireNonNull(type, "The configuration subtype must be an interface.");
        validateSubtypeInterface(type);
        if (WatchableConfig.class.isAssignableFrom(type)
                && !(config instanceof WatchableConfig)) {
            throw new ConfigException(
                    "The proxied configuration instance must extend WatchableConfig.");
        } else if (MutableConfig.class.isAssignableFrom(type)
                && !(config instanceof MutableConfig)) {
            throw new ConfigException(
                    "The proxied configuration instance must extend MutableConfig.");
        }
        final ConfigSubtype handler = new ConfigSubtype(config, type);
        return type.cast(Proxy.newProxyInstance(
                classLoader,
                new Class<?>[] { type },
                handler));
    }

    /**
     * Validates the configuration subtype interface.
     *
     * @param configInterface The configuration interface class to validate.
     * @throws ConfigException If the interface is not valid.
     */
    private static void validateSubtypeInterface(
            final @NotNull Class<?> configInterface) {
        if (!configInterface.isInterface()) {
            throw new ConfigException(
                    "The configuration subtype must be an interface.");
        }
        for (final Method method : configInterface.getDeclaredMethods()) {
            if (Modifier.isStatic(method.getModifiers())) {
                continue;
            }
            if (!method.isDefault()) {
                throw new ConfigException(
                        "The configuration subtype must only contain default methods. " + method);
            }
        }
        for (final Class<?> iface : configInterface.getInterfaces()) {
            if (!Config.class.equals(iface)
                    && !MutableConfig.class.equals(iface)
                    && !WatchableConfig.class.equals(iface)) {
                validateSubtypeInterface(iface);
            }
        }
    }

    /**
     * {@inheritDoc}
    */
    @Override
    public Object invoke(
            final Object proxy,
            final @NotNull Method method,
            final @NotNull Object[] args)
    throws Throwable {
        final Class<?> declaringClass = method.getDeclaringClass();
        if (Object.class.equals(declaringClass)) {
            return handleObjectMethod(method, args);
        }
        if (!PROXYED_TYPES.contains(declaringClass)) {
            return this.lookup
                    .findSpecial(
                            declaringClass,
                            method.getName(),
                            MethodType.methodType(
                                method.getReturnType(),
                                method.getParameterTypes()),
                            this.extendedType)
                    .bindTo(proxy)
                    .invokeWithArguments(args);
        }
        try {
            return method.invoke(this.instance, args);
        } catch (final  InvocationTargetException e) {
            if (e.getCause() instanceof RuntimeException) {
                throw (RuntimeException) e.getCause();
            } else {
                throw new ConfigException(e.getCause());
            }
        }
    }

    /**
     * Handles {@code Object} methods invocations.
     * 
     * @param method The invoked method.
     * @param args The method arguments.
     * @return The method invocation result.
     * @throws ReflectiveOperationException If an error occurs during method
     * invocation.
     */
    protected Object handleObjectMethod(
            final @NotNull Method method,
            final Object[] args)
    throws ReflectiveOperationException {
        final Object result;
        if (OBJECT_EQUALS.equals(method)) {
            result = proxyEquals(args[0]);
        } else {
            result = method.invoke(this, args);
        }
        return result;
    }

    /**
     * Checks equality with another proxy instance.
     * 
     * @param other The other proxy instance.
     * @return {@code true} if both proxies are equal,
     *         {@code false} otherwise.
     */
    protected boolean proxyEquals(
            final Object other) {
        if (this == other) {
            return true;
        }
        if (other == null || !Proxy.isProxyClass(other.getClass())) {
            return false;
        }
        return this.equals(Proxy.getInvocationHandler(other));
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public int hashCode() {
        return Objects.hash(
                this.instance,
                this.extendedType);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        ConfigSubtype other = (ConfigSubtype) obj;
        return Objects.equals(this.instance, other.instance)
                && Objects.equals(this.extendedType, other.extendedType);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String toString() {
        return "ConfigSubtype [instance=" + this.instance + ", extendedType=" + this.extendedType + "]";
    }
}