/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.camel.xml.jaxb.springboot;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.lang.annotation.Annotation;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

import jakarta.xml.bind.annotation.XmlAccessOrder;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlAnyAttribute;
import jakarta.xml.bind.annotation.XmlAnyElement;
import jakarta.xml.bind.annotation.XmlAttachmentRef;
import jakarta.xml.bind.annotation.XmlAttribute;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlElementDecl;
import jakarta.xml.bind.annotation.XmlElementRef;
import jakarta.xml.bind.annotation.XmlElementRefs;
import jakarta.xml.bind.annotation.XmlElementWrapper;
import jakarta.xml.bind.annotation.XmlElements;
import jakarta.xml.bind.annotation.XmlEnum;
import jakarta.xml.bind.annotation.XmlEnumValue;
import jakarta.xml.bind.annotation.XmlID;
import jakarta.xml.bind.annotation.XmlIDREF;
import jakarta.xml.bind.annotation.XmlInlineBinaryData;
import jakarta.xml.bind.annotation.XmlList;
import jakarta.xml.bind.annotation.XmlMimeType;
import jakarta.xml.bind.annotation.XmlMixed;
import jakarta.xml.bind.annotation.XmlNs;
import jakarta.xml.bind.annotation.XmlRegistry;
import jakarta.xml.bind.annotation.XmlRootElement;
import jakarta.xml.bind.annotation.XmlSchema;
import jakarta.xml.bind.annotation.XmlSchemaType;
import jakarta.xml.bind.annotation.XmlSchemaTypes;
import jakarta.xml.bind.annotation.XmlSeeAlso;
import jakarta.xml.bind.annotation.XmlTransient;
import jakarta.xml.bind.annotation.XmlType;
import jakarta.xml.bind.annotation.XmlValue;
import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapters;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.aot.hint.TypeReference;
import org.springframework.asm.ClassReader;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.util.ReflectionUtils;

import static org.apache.camel.spring.boot.aot.ReflectionHelper.applyIfMatch;
import static org.apache.camel.spring.boot.aot.ReflectionHelper.getClassesByAnnotations;
import static org.apache.camel.spring.boot.aot.RuntimeHintsHelper.registerClassHierarchy;

final class JAXBRuntimeHints implements RuntimeHintsRegistrar {

    /**
     * The logger.
     */
    private static final Logger LOG = LoggerFactory.getLogger(JAXBRuntimeHints.class);

    private static final List<Class<? extends Annotation>> JAXB_ROOT_ANNOTATIONS = List.of(XmlRootElement.class,
            XmlType.class, XmlRegistry.class, XmlJavaTypeAdapter.class, XmlSeeAlso.class);

    private static final List<Class<? extends Annotation>> JAXB_ANNOTATIONS = List.of(XmlAccessorType.class,
            XmlAnyAttribute.class, XmlAnyElement.class, XmlAttachmentRef.class, XmlAttribute.class, XmlElement.class,
            XmlElementDecl.class, XmlElementRef.class, XmlElementRefs.class, XmlElements.class, XmlElementWrapper.class,
            XmlEnum.class, XmlEnumValue.class, XmlID.class, XmlIDREF.class, XmlInlineBinaryData.class, XmlList.class,
            XmlMimeType.class, XmlMixed.class, XmlNs.class, XmlRegistry.class, XmlRootElement.class, XmlSchema.class,
            XmlSchemaType.class, XmlSchemaTypes.class, XmlSeeAlso.class, XmlTransient.class, XmlType.class,
            XmlValue.class, XmlJavaTypeAdapter.class, XmlJavaTypeAdapters.class);

    private static final List<String> NATIVE_PROXY_DEFINITIONS = List.of(
            "org.glassfish.jaxb.core.marshaller.CharacterEscapeHandler",
            "com.sun.xml.txw2.output.CharacterEscapeHandler", "org.glassfish.jaxb.core.v2.schemagen.episode.Bindings",
            "org.glassfish.jaxb.core.v2.schemagen.episode.SchemaBindings",
            "org.glassfish.jaxb.core.v2.schemagen.episode.Klass",
            "org.glassfish.jaxb.core.v2.schemagen.episode.Package",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Annotated",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Annotation",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Any",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Appinfo",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.AttrDecls",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.AttributeType",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.ComplexContent",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.ComplexExtension",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.ComplexRestriction",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.ComplexType",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.ComplexTypeHost",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.ComplexTypeModel",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.ContentModelContainer",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Documentation",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Element",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.ExplicitGroup",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.ExtensionType",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.FixedOrDefault",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Import",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.List",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.LocalAttribute",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.LocalElement",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.NestedParticle",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.NoFixedFacet",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Occurs",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Particle",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Redefinable",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Schema",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.SchemaTop",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.SimpleContent",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.SimpleDerivation",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.SimpleExtension",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.SimpleRestriction",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.SimpleRestrictionModel",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.SimpleType",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.SimpleTypeHost",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.TopLevelAttribute",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.TopLevelElement",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.TypeDefParticle",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.TypeHost",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Union",
            "org.glassfish.jaxb.runtime.v2.schemagen.xmlschema.Wildcard", "com.sun.xml.txw2.TypedXmlWriter");
    private static final List<String> JAXB_RUNTIME_CLASSES = List.of("org.glassfish.jaxb.runtime.v2.ContextFactory",
            "com.sun.xml.internal.stream.XMLInputFactoryImpl", "com.sun.xml.internal.stream.XMLOutputFactoryImpl",
            "com.sun.org.apache.xpath.internal.functions.FuncNot",
            "org.glassfish.jaxb.core.v2.model.nav.ReflectionNavigator",
            "org.glassfish.jaxb.runtime.v2.runtime.property.SingleElementLeafProperty",
            "org.glassfish.jaxb.runtime.v2.runtime.property.ArrayElementLeafProperty",
            "org.glassfish.jaxb.runtime.v2.runtime.property.SingleElementNodeProperty",
            "org.glassfish.jaxb.runtime.v2.runtime.property.SingleReferenceNodeProperty",
            "org.glassfish.jaxb.runtime.v2.runtime.property.SingleMapNodeProperty",
            "org.glassfish.jaxb.runtime.v2.runtime.property.ArrayElementNodeProperty",
            "org.glassfish.jaxb.runtime.v2.runtime.property.ArrayReferenceNodeProperty",
            "com.sun.org.apache.xerces.internal.impl.dv.xs.SchemaDVFactoryImpl", XmlAccessOrder.class.getName());

    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        List<Class<?>> classes = getClassesByAnnotations(classLoader, JAXB_ROOT_ANNOTATIONS);
        for (Class<?> c : classes) {
            if (c.isAnnotationPresent(XmlSeeAlso.class)) {
                XmlSeeAlso annotation = c.getAnnotation(XmlSeeAlso.class);
                for (Class<?> type : annotation.value()) {
                    hints.reflection().registerType(type, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS);
                }
            }
            applyIfMatch(c, XmlJavaTypeAdapter.class, XmlJavaTypeAdapter::value,
                    type -> hints.reflection().registerType(type, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
                            MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.INVOKE_PUBLIC_METHODS,
                            MemberCategory.DECLARED_FIELDS));
            hints.reflection().registerType(c, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
                    MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.INVOKE_PUBLIC_METHODS,
                    MemberCategory.DECLARED_FIELDS);
        }
        boolean classDetected = false;
        for (String className : getClassesFromIndexes(classLoader)) {
            registerClassHierarchy(hints, classLoader, className, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
                    MemberCategory.INTROSPECT_DECLARED_METHODS, MemberCategory.DECLARED_FIELDS);
            classDetected = true;
        }
        if (classes.isEmpty() && !classDetected) {
            return;
        }
        // Register all JAXB indexes
        hints.resources().registerPattern("*/jaxb.index");

        hints.reflection().registerTypeIfPresent(classLoader, "jakarta.xml.bind.annotation.W3CDomHandler",
                MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS);
        for (Class<?> c : JAXB_ANNOTATIONS) {
            hints.reflection().registerType(c, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
                    MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.INVOKE_PUBLIC_METHODS);
        }
        hints.proxies().registerJdkProxy(TypeReference.of(XmlSeeAlso.class),
                TypeReference.of("org.glassfish.jaxb.core.v2.model.annotation.Locatable"));
        for (String className : NATIVE_PROXY_DEFINITIONS) {
            hints.proxies().registerJdkProxy(TypeReference.of(className));
        }
        for (String className : JAXB_RUNTIME_CLASSES) {
            hints.reflection().registerTypeIfPresent(classLoader, className, MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
                    MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.INVOKE_PUBLIC_METHODS);
        }
        // Register the JAXB resource bundles
        hints.reflection().registerTypeIfPresent(classLoader, "jakarta.xml.bind.Messages");
        hints.resources().registerPattern("jakarta/xml/bind/Messages.properties");
        hints.reflection().registerTypeIfPresent(classLoader, "jakarta.xml.bind.helpers.Messages");
        hints.resources().registerPattern("jakarta/xml/bind/helpers/Messages.properties");
    }

    private static List<String> getClassesFromIndexes(ClassLoader classLoader) {
        List<String> classNames = new ArrayList<>();
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(classLoader);
        try {
            for (Resource resource : resolver.getResources("classpath*:**/jaxb.index")) {
                String filename = resource.getFilename();
                if (filename == null || filename.isBlank()) {
                    continue;
                }
                String packageName = getPackageName(resource, "jaxb.index");
                if (packageName == null) {
                    LOG.debug("The package name could not be found for the resource {}", resource);
                    continue;
                }
                try (BufferedReader reader = new BufferedReader(
                        new StringReader(resource.getContentAsString(StandardCharsets.UTF_8)))) {
                    String line = reader.readLine();
                    while (line != null) {
                        if (line.startsWith("#") || line.isBlank()) {
                            line = reader.readLine();
                            continue;
                        }
                        String className = "%s%s".formatted(packageName, line.trim());
                        LOG.debug("Found the class {} to register", className);
                        classNames.add(className);
                        line = reader.readLine();
                    }
                }
            }
        } catch (IOException e) {
            LOG.debug("Could not load the JAXB indexes: {}", e.getMessage());
        }
        return classNames;
    }

    /**
     * Give the package name of the given resource.
     *
     * @param resource
     *            the resource for which the package name is expected.
     * @param fileName
     *            the name of file corresponding to the resource
     *
     * @return the package name if it could be found, {@code null} otherwise.
     *
     * @throws IOException
     *             an error occurs while trying to retrieve the package name.
     */
    private static String getPackageName(Resource resource, String fileName) throws IOException {
        URL url = resource.getURL();
        String protocol = url.getProtocol();
        String packageName = null;
        if ("jar".equals(protocol)) {
            String path = url.getPath();
            String suffix = ".jar!/";
            int index = path.indexOf(suffix);
            if (index == -1) {
                LOG.trace("The jar suffix could not be found in {}", path);
            } else {
                packageName = path.substring(index + suffix.length(), path.length() - fileName.length());
            }
        } else if (resource.isFile()) {
            File file = resource.getFile();
            File[] files = file.getParentFile().listFiles((dir, name) -> name.endsWith(".class"));
            if (files != null && files.length > 0) {
                try (InputStream is = new FileInputStream(files[0])) {
                    ClassReader reader = new ClassReader(is);
                    String className = reader.getClassName();
                    int index = className.lastIndexOf('/');
                    if (index == -1) {
                        packageName = "";
                    } else {
                        packageName = className.substring(0, index + 1);
                    }
                }
            } else {
                LOG.trace("No class file could be found in {}", file.getParentFile());
            }
        }
        if (packageName != null) {
            packageName = packageName.replace('/', '.');
        }
        return packageName;
    }
}
