ConfigService.java

/*
 * SPDX-FileCopyrightText: 2025 kaumei.io
 * SPDX-License-Identifier: Apache-2.0
 */
package io.kaumei.jdbc.anno.ctx;

import io.kaumei.jdbc.anno.Processor;
import io.kaumei.jdbc.anno.ProcessorEnvironment;
import io.kaumei.jdbc.anno.ProcessorException;
import io.kaumei.jdbc.anno.ProcessorSteps;
import io.kaumei.jdbc.anno.model.AnnoMap;
import io.kaumei.jdbc.anno.model.JavaAnnoType;
import io.kaumei.jdbc.anno.model.SourceMethod;
import io.kaumei.jdbc.anno.msg.JdbcMsg;
import io.kaumei.jdbc.annotation.JdbcUpdate;
import io.kaumei.jdbc.annotation.config.*;
import org.jspecify.annotations.Nullable;

import javax.lang.model.element.*;
import java.lang.annotation.Annotation;
import java.nio.file.Path;
import java.util.*;

import static io.kaumei.jdbc.anno.Processor.OPTION_KEY_CONFIG;
import static io.kaumei.jdbc.anno.Processor.OPTION_KEY_DEBUG_FOLDER;
import static java.util.Objects.requireNonNull;

public class ConfigService implements ProcessorSteps {

    public final Option<Integer> BATCH_SIZE;
    public final Option<JdbcFetchDirection.Kind> FETCH_DIRECTION;
    public final Option<Integer> FETCH_SIZE;
    public final Option<Integer> MAX_ROWS;
    public final Option<JdbcNoMoreRows.Kind> NO_MORE_ROWS;
    public final Option<JdbcNoRows.Kind> NO_ROWS;
    public final Option<Integer> QUERY_TIMEOUT;
    public final Option<JdbcResultSetConcurrency.Kind> RESULT_SET_CONCURRENCY;
    public final Option<JdbcResultSetType.Kind> RESULT_SET_TYPE;
    public final Option[] ALL_OPTIONS;

    // ----- services
    private final Context ctx;
    private final JavaMessenger logger;

    // ----- state
    private final @Nullable String configKey;
    private final @Nullable String debugFolder;
    private JdbcUpdate.GeneratedValues jdbcReturnGeneratedValues = JdbcUpdate.GeneratedValues.GENERATED_KEYS;
    private String generatedClassSuffix = "Jdbc";
    private int maxCollectionPlaceholders = 1_000;
    private int maxTotalPlaceholders = 2_000;

    private final Map<Option<?>, Object> option2default = new HashMap<>();

    private final Set<ExecutableElement> jdbcToJava = new HashSet<>();
    private final Set<ExecutableElement> javaToJdbc = new HashSet<>();
    private final Set<Name> parentCycleDetection = new LinkedHashSet<>();

    // ------------------------------------------------------------------------

    public ConfigService(Context ctx, Map<String, String> options) {
        this.ctx = requireNonNull(ctx);
        this.logger = requireNonNull(ctx.logger);
        this.configKey = options.get(OPTION_KEY_CONFIG);
        this.debugFolder = options.get(OPTION_KEY_DEBUG_FOLDER);

        BATCH_SIZE = create(JdbcBatchSize.class, false, 1);
        FETCH_DIRECTION = create(JdbcFetchDirection.class, JdbcFetchDirection.Kind.class, JdbcFetchDirection.Kind.UNSPECIFIED);
        FETCH_SIZE = create(JdbcFetchSize.class, 0);
        MAX_ROWS = create(JdbcMaxRows.class, 0);
        NO_MORE_ROWS = create(JdbcNoMoreRows.class, JdbcNoMoreRows.Kind.class, JdbcNoMoreRows.Kind.UNSPECIFIED, false);
        NO_ROWS = create(JdbcNoRows.class, JdbcNoRows.Kind.class, JdbcNoRows.Kind.UNSPECIFIED, false);
        QUERY_TIMEOUT = create(JdbcQueryTimeout.class, 0);
        RESULT_SET_CONCURRENCY = create(JdbcResultSetConcurrency.class, JdbcResultSetConcurrency.Kind.class, JdbcResultSetConcurrency.Kind.UNSPECIFIED);
        RESULT_SET_TYPE = create(JdbcResultSetType.class, JdbcResultSetType.Kind.class, JdbcResultSetType.Kind.UNSPECIFIED);

        ALL_OPTIONS = new Option[]{BATCH_SIZE, FETCH_DIRECTION, FETCH_SIZE, MAX_ROWS, NO_MORE_ROWS, NO_ROWS, QUERY_TIMEOUT,
                RESULT_SET_CONCURRENCY, RESULT_SET_TYPE};
    }

    // ------------------------------------------------------------------------

    @Override
    public void process(ProcessorEnvironment roundEnv) {
        this.logger.info("Process Kaumei JDBC processor configuration.");

        checkJdbcConfigNotOnJdbcInterface(roundEnv);

        var configByAnno = configByAnno(roundEnv);
        var configByOptions = configByOption();
        if (configByAnno != null && configByOptions != null && !configByAnno.equals(configByOptions)) {
            var msg = "Given config by annotation and by processor option are not equal.";
            this.logger.error(configByAnno, msg);
            this.logger.error(configByOptions, msg);
        }
        loadConfig(configByAnno != null ? configByAnno : configByOptions);
        this.logger.always("option2default:", option2default);
        this.logger.always("jdbcToJava....:", jdbcToJava);
        this.logger.always("javaToJdbc....:", javaToJdbc);
        parentCycleDetection.clear();
    }

    private void checkJdbcConfigNotOnJdbcInterface(ProcessorEnvironment roundEnv) {
        for (var config : roundEnv.jdbcConfig()) {
            if (config.getKind() == ElementKind.INTERFACE
                    && config instanceof TypeElement te
                    && roundEnv.jdbcInterfaces().contains(te)) {
                this.logger.error(config,
                        "@JdbcConfig must not be used on an interface that declares @Jdbc methods.");
            }
        }
    }

    private void loadConfig(@Nullable TypeElement jdbcConfig) {
        // ----- put the default values
        for (var option : ALL_OPTIONS) {
            option2default.put(option, option.defaultValue());
        }
        // overwrite some defaults
        option2default.put(BATCH_SIZE, 1000);
        option2default.put(NO_MORE_ROWS, JdbcNoMoreRows.Kind.THROW_EXCEPTION);
        option2default.put(NO_ROWS, JdbcNoRows.Kind.THROW_EXCEPTION);

        if (jdbcConfig == null) {
            this.logger.debug("No config found.");
            return;
        }
        processConfig(jdbcConfig);
    }

    private void processConfig(TypeElement configType) {
        if (!parentCycleDetection.add(configType.getQualifiedName())) {
            this.logger.warn("Encounter cycle in parent definition.", parentCycleDetection);
            return;
        }

        var annoMap = AnnoMap.of(ctx, configType);

        var cfg = annoMap.getOpt(ctx.JDBC_CONFIG_PROPS);
        if (cfg != null && cfg.parent() != null) {
            var elem = ctx.asElement(cfg.parent());
            if (elem instanceof TypeElement te) {
                processConfig(te);
            } else {
                this.logger.warn("Ignore parent.", elem);
            }
        }
        this.logger.debug("Process config:", configType);

        // ----- by default we search for converter in the config type
        processConverter(configType);
        // ----- next we check all other referenced types
        if (cfg != null) {
            jdbcReturnGeneratedValues = switch (cfg.returnGeneratedValues()) {
                case GENERATED_KEYS -> JdbcUpdate.GeneratedValues.GENERATED_KEYS;
                case EXECUTE_QUERY -> JdbcUpdate.GeneratedValues.EXECUTE_QUERY;
                default -> jdbcReturnGeneratedValues;
            };
            if (isValidGeneratedClassSuffix(cfg.generatedClassSuffix())) {
                generatedClassSuffix = cfg.generatedClassSuffix();
            } else {
                logger.error(configType, "@JdbcConfig.generatedClassSuffix must be a non-blank Java class name suffix.");
            }
            if (cfg.maxCollectionPlaceholders() >= 1) {
                maxCollectionPlaceholders = cfg.maxCollectionPlaceholders();
            } else {
                logger.error(configType, "@JdbcConfig.maxCollectionPlaceholders must be >= 1.");
            }
            if (cfg.maxTotalPlaceholders() >= 1) {
                maxTotalPlaceholders = cfg.maxTotalPlaceholders();
            } else {
                logger.error(configType, "@JdbcConfig.maxTotalPlaceholders must be >= 1.");
            }

            for (var converterType : cfg.converter()) {
                processConverter(ctx.asElement(converterType));
            }
        }

        if (annoMap.getOpt(ctx.JDBC_LOG_LEVEL) != null) {
            logger.warn("Not supported by now. Use annotation option " + Processor.OPTION_KEY_LOG_LEVEL);
        }
        for (var option : ALL_OPTIONS) {
            var value = getOption(annoMap, option, configType);
            if (value != null) {
                option2default.put(option, value);
            }
        }

        if (annoMap.hasUnused()) {
            throw new ProcessorException(JdbcMsg.unusedAnnotations(configType, annoMap.unused()).toString(), configType);
        }
    }

    private void processConverter(Element element) {
        if (element instanceof ExecutableElement executable) {
            var hasJdbcToJava = ctx.JDBC_TO_JAVA.hasAnno(executable);
            var hasJavaToJdbc = ctx.JAVA_TO_JDBC.hasAnno(executable);
            if (hasJdbcToJava && hasJavaToJdbc) {
                this.logger.error(executable, "Could not have both annotations @JdbcToJava and @JavaToJdbc present at an element.");
            } else if (hasJdbcToJava) {
                jdbcToJava.add(executable);
            } else if (hasJavaToJdbc) {
                javaToJdbc.add(executable);
            }
        } else if (element instanceof TypeElement type) {
            for (var child : type.getEnclosedElements()) {
                processConverter(child);
            }
        } else {
            logger.warn(element, "Unsupported element type for annotations. Annotation is ignored.", "kind", element.getKind());
        }
    }

    /**
     * Search for a @JdbcConfig annotated class. there must be none or exactly one.
     * @return found type or null
     */
    private @Nullable TypeElement configByAnno(ProcessorEnvironment roundEnv) {
        if (roundEnv.jdbcConfig().size() == 1) {
            var jdbcConfig = roundEnv.jdbcConfig().iterator().next();
            if (jdbcConfig instanceof TypeElement te) {
                return te;
            }
            this.logger.error(jdbcConfig, "Incompatible type for configuration.");
        } else if (roundEnv.jdbcConfig().size() > 1) {
            this.logger.error("To many configurations found.", "configs", roundEnv.jdbcConfig());
        }
        return null;
    }

    /**
     * Search for a @JdbcConfig defined class by processor options.
     * @return found type or null
     */
    private @Nullable TypeElement configByOption() {
        if (configKey != null) {
            var jdbcConfig = this.ctx.getTypeElementOpt(configKey);
            if (jdbcConfig == null) {
                this.logger.warn("Given config could not be found.", "config", configKey);
            } else if (!ctx.JDBC_CONFIG_PROPS.hasAnno(jdbcConfig)) {
                this.logger.warn(jdbcConfig, "Given config is missing @JdbcConfig annotation.");
            } else {
                return jdbcConfig;
            }
        }
        return null;

    }

    // ------------------------------------------------------------------------

    public boolean dump() {
        return debugFolder != null;
    }

    public Path dumpFolder() {
        return Path.of(requireNonNull(debugFolder));
    }

    // ------------------------------------------------------------------------


    public Set<ExecutableElement> jdbcToJava() {
        return jdbcToJava;
    }

    public Set<ExecutableElement> javaToJdbc() {
        return javaToJdbc;
    }

    // ------------------------------------------------------------------------

    public JdbcUpdate.GeneratedValues jdbcReturnGeneratedValues() {
        return this.jdbcReturnGeneratedValues;
    }

    public String generatedClassSuffix() {
        return generatedClassSuffix;
    }

    public int maxCollectionPlaceholders() {
        return maxCollectionPlaceholders;
    }

    public int maxTotalPlaceholders() {
        return maxTotalPlaceholders;
    }

    private static boolean isValidGeneratedClassSuffix(String suffix) {
        if (suffix.isBlank()) {
            return false;
        }
        for (int i = 0; i < suffix.length(); i++) {
            if (!Character.isJavaIdentifierPart(suffix.charAt(i))) {
                return false;
            }
        }
        return true;
    }

    @Nullable
    public <T> OptionValue<T> search(Option<T> option, SourceMethod method) {
        var name = method.parameter().paramNameFor(option);
        if (name != null) {
            return new OptionValue.Dynamic<>(option, name);
        }
        T value = this.searchOnMethod(option, method);
        return option.isNotDefaultValue(value)
                ? new OptionValue.Static<>(option, value)
                : null;
    }

    @SuppressWarnings("unchecked")
    public <T> T searchOnMethod(Option<T> option, SourceMethod method) {
        var value = getOption(method.annoMap(), option, method.method());
        if (value != null) {
            return value;
        }
        value = option.anno().getAnnoOpt(method.parent());
        if (value != null && (option.unsetAllowed() || option.isNotDefaultValue(value))) {
            validateStaticOptionValue(method.parent(), option, value);
            return value;
        }
        return requireNonNull((T) option2default.get(option));
    }

    private <T> @Nullable T getOption(AnnoMap annoMap, Option<T> option, Element element) {
        var value = option.unsetAllowed()
                ? annoMap.getOpt(option)
                : annoMap.getIfNotDefault(option);
        if (value != null) {
            validateStaticOptionValue(element, option, value);
        }
        return value;
    }

    private <T> void validateStaticOptionValue(Element element, Option<T> option, T value) {
        if (option.isInvalidValue(value)) {
            logger.error(element, invalidValueMessage(option, value));
        }
    }

    public static <T> String invalidValueMessage(Option<T> option, T value) {
        return "Invalid value for @" + option.anno().typeElement().getSimpleName() + ": " + value;
    }

    // ------------------------------------------------------------------------
    // new code
    // ------------------------------------------------------------------------

    private Option<Integer> create(Class<? extends Annotation> anno, int minValue) {
        return create(anno, true, minValue);
    }

    private Option<Integer> create(Class<? extends Annotation> anno, boolean unsetAllowed, int minValue) {
        var annoType = new JavaAnnoType<>(this.ctx, anno, Integer.class, map -> map.getInteger("value", -1));
        return new Option<>(annoType, -1, unsetAllowed, minValue);
    }

    private <T extends Enum<T>> Option<T> create(Class<? extends Annotation> anno, Class<T> cls, T defaultValue) {
        return create(anno, cls, defaultValue, true);
    }

    private <T extends Enum<T>> Option<T> create(Class<? extends Annotation> anno, Class<T> cls, T defaultValue, boolean unsetAllowed) {
        var annoType = new JavaAnnoType<>(this.ctx, anno, cls, map -> map.getEnum("value", cls, defaultValue));
        return new Option<>(annoType, defaultValue, unsetAllowed, null);
    }

    // ------------------------------------------------------------------------

    public record Option<T>(JavaAnnoType<T> anno, T defaultValue, boolean unsetAllowed, @Nullable Integer minValue) {
        public T cast(Object value) {
            return anno.cast(value);
        }

        public boolean isInvalidValue(Object value) {
            return minValue != null
                    && value instanceof Integer integer
                    && !defaultValue.equals(value)
                    && integer < minValue;
        }

        public boolean isDefaultValue(@Nullable Object value) {
            return defaultValue.equals(value);
        }

        public boolean isNotDefaultValue(@Nullable Object value) {
            return value != null
                    && defaultValue.getClass().equals(value.getClass())
                    && !defaultValue.equals(value);
        }

    }

    public sealed interface OptionValue<T> permits OptionValue.Static, OptionValue.Dynamic {
        Option<T> option();

        record Static<T>(Option<T> option, T value) implements OptionValue<T> {
        }

        record Dynamic<T>(Option<T> option, String paramName) implements OptionValue<T> {
        }
    }

}