/*
 * 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.sis.metadata;

import java.util.Comparator;
import java.util.Map;
import java.util.HashMap;
import java.lang.reflect.Method;
import jakarta.xml.bind.annotation.XmlType;
import org.opengis.annotation.UML;
import org.opengis.annotation.Obligation;

// Specific to the main branch:
import org.opengis.metadata.citation.ResponsibleParty;


/**
 * The comparator for sorting the properties in a metadata object.
 * Since the comparator uses (among other criteria) the property names, this class
 * incidentally defines static methods for inferring those names from the methods.
 *
 * <p>This comparator uses the following criteria, in priority order:</p>
 * <ol>
 *   <li>Deprecated properties are last.</li>
 *   <li>If the property order is specified by a {@link XmlType} annotation,
 *       then this comparator complies to that order.</li>
 *   <li>Otherwise this comparator sorts mandatory methods first, followed by
 *       conditional methods, then optional ones.</li>
 *   <li>If the order cannot be inferred from the above, then the comparator
 *       fallbacks on alphabetical order.</li>
 * </ol>
 *
 * @author  Martin Desruisseaux (Geomatys)
 */
final class PropertyComparator implements Comparator<Method> {
    /**
     * The prefix for getters on boolean values.
     */
    private static final String IS = "is";

    /**
     * The prefix for getters (general case).
     */
    private static final String GET = "get";

    /**
     * The prefix for setters.
     */
    static final String SET = "set";

    /**
     * Methods and property names specified in the {@link XmlType} annotation.
     * Entries description:
     *
     * <ul>
     *   <li>Keys in this map are either {@link String} or {@link Method} instances:
     *     <ul>
     *       <li>{@code String} keys property names as given by {@link XmlType#propOrder()}.
     *           They are computed at construction time and do not change after construction.</li>
     *       <li>{@code Method} keys will be added after construction, only as needed.</li>
     *     </ul>
     *   </li>
     *
     *   <li>Key is associated to an index that specify its position in descending order.
     *       For example, the property associated to integer 0 shall be sorted last.
     *       This descending order is only an implementation convenience.</li>
     * </ul>
     */
    private final Map<Object,Integer> order;

    /**
     * The implementation class, or the interface is the implementation class is unknown.
     */
    private final Class<?> implementation;

    /**
     * Creates a new comparator for the given implementation class.
     *
     * @param implementation  the implementation class, or the interface if the implementation class is unknown.
     * @param standardImpl    the implementation specified by the {@link MetadataStandard}, or {@code null} if none.
     *                        This is the same than {@code implementation} unless a custom implementation is used.
     */
    PropertyComparator(Class<?> implementation, final Class<?> standardImpl) {
        order = new HashMap<>();
        defineOrder(implementation, order);
        if (order.isEmpty() && standardImpl != null && !standardImpl.isAssignableFrom(implementation)) {
            /*
             * We enter in this block only if the user specified its own metadata implementation and that
             * custom implementation does not have any JAXB @XmlType annotation. In such case this method
             * cannot sort the properties. So we will use the class defined by org.apache.sis.metadata.iso
             * instead.
             */
            implementation = standardImpl;
            defineOrder(implementation, order);
        }
        this.implementation = implementation;
    }

    /**
     * Uses the {@link XmlType} annotation for defining the property order.
     *
     * @param implementation  the implementation class where to search for {@code XmlType} annotation.
     * @param order           the {@link #order} map where to store the properties order.
     */
    private static void defineOrder(Class<?> implementation, final Map<Object,Integer> order) {
        do {
            final XmlType xml = implementation.getAnnotation(XmlType.class);
            if (xml != null) {
                final String[] propOrder = xml.propOrder();
                for (int i=propOrder.length; --i>=0;) {
                    /*
                     * Add the entries in reverse order because we are iterating from the child class to
                     * the parent class, and we want the properties in the parent class to be sorted first.
                     * If duplicated properties are found, keep the first occurence (i.e. sort the property
                     * with the most specialized child that declared it).
                     *
                     * We make an exception for ResponsibleParty.role, which should be replaced by Party.role
                     * but this replacement is not yet effective in GeoAPI 3.0.
                     */
                    final String prop = propOrder[i];
                    if (!"role".equals(prop) || !ResponsibleParty.class.isAssignableFrom(implementation)) {
                        order.putIfAbsent(prop, order.size());
                    }
                }
            }
            implementation = implementation.getSuperclass();
        } while (implementation != null);
    }

    /**
     * Returns {@code true} if the given method is deprecated, either in the interface that declare the method
     * or in the implementation class. A method may be deprecated in the implementation but not in the interface
     * when the implementation has been updated for a new standard, while the interface is still reflecting the
     * old standard.
     *
     * @param  implementation  the implementation class, or the interface is the implementation class is unknown.
     * @param  method          the method to check for deprecation.
     * @return {@code true} if the method is deprecated.
     */
    static boolean isDeprecated(final Class<?> implementation, Method method) {
        if (!MetadataStandard.IMPLEMENTATION_CAN_ALTER_API) {
            return method.isAnnotationPresent(Deprecated.class);
        }
        if (method.isAnnotationPresent(Deprecated.class)) {
            return true;
        }
        if (method.getDeclaringClass() == implementation) {
            return false;
        }
        try {
            method = implementation.getMethod(method.getName(), (Class[]) null);
        } catch (NoSuchMethodException e) {
            /*
             * Should never happen since the implementation is supposed to implement
             * the interface that declare the method given in argument.
             */
            throw new AssertionError(e);
        }
        return method.isAnnotationPresent(Deprecated.class);
    }

    /**
     * Compares the given methods for order.
     */
    @Override
    public int compare(final Method m1, final Method m2) {
        final boolean deprecated = isDeprecated(implementation, m1);
        if (deprecated != isDeprecated(implementation, m2)) {
            return deprecated ? +1 : -1;
        }
        int c = indexOf(m2) - indexOf(m1);                          // indexOf(…) are sorted in descending order.
        if (c == 0) {
            final UML a1 = m1.getAnnotation(UML.class);
            final UML a2 = m2.getAnnotation(UML.class);
            if (a1 != null) {
                if (a2 == null) return +1;                          // Sort annotated elements first.
                c = order(a1) - order(a2);                          // Mandatory elements must be first.
                if (c == 0) {
                    // Fallback on alphabetical order.
                    c = a1.identifier().compareToIgnoreCase(a2.identifier());
                }
                return c;
            } else if (a2 != null) {
                return -1;                                          // Sort annotated elements first.
            }
            c = m1.getName().compareToIgnoreCase(m2.getName());     // Fallback on alphabetical order.
        }
        return c;
    }

    /**
     * Returns a higher number for obligation which should be first.
     */
    private static int order(final UML uml) {
        final Obligation obligation = uml.obligation();
        if (obligation != null) {
            switch (obligation) {
                case MANDATORY:   return 1;
                case CONDITIONAL: return 2;
                case OPTIONAL:    return 3;
                case FORBIDDEN:   return 4;
            }
        }
        return 5;
    }

    /**
     * Returns the index of the given method, or -1 if the method is not found.
     * If positive, the index returned by this method correspond to a sorting in descending order.
     */
    private int indexOf(final Method method) {
        /*
         * Check the cached value computed by previous call to 'indexOf(…)'.
         * Example: "getExtents"
         */
        Integer index = order.get(method);
        if (index == null) {
            /*
             * Check the value computed from @XmlType.propOrder() value.
             * Inferred from the method name, so name is often plural.
             * Example: "extents"
             */
            String name = method.getName();
            name = toPropertyName(name, prefix(name).length());
            index = order.get(name);
            if (index == null) {
                /*
                 * Do not happen, except when we have private methods or deprecated public methods
                 * used as bridge between legacy and more recent standards (e.g. ISO 19115:2003 to
                 * ISO 19115:2014), especially when multiplicity changed between the two standards.
                 * Example: "extent"
                 */
                final UML uml = method.getAnnotation(UML.class);
                if (uml == null || (index = order.get(uml.identifier())) == null) {
                    index = -1;
                }
            }
            order.put(method, index);
        }
        return index;
    }

    /**
     * Returns the prefix of the specified method name. If the method name doesn't starts with
     * a prefix (for example {@link org.opengis.metadata.quality.ConformanceResult#pass()}),
     * then this method returns an empty string.
     */
    static String prefix(final String name) {
        if (name.startsWith(GET)) {
            return GET;
        }
        if (name.startsWith(IS)) {
            return IS;
        }
        if (name.startsWith(SET)) {
            return SET;
        }
        return "";
    }

    /**
     * Returns {@code true} if the specified string starting at the specified index contains
     * no lower case characters. The characters don't have to be in upper case however (e.g.
     * non-alphabetic characters)
     */
    private static boolean isAcronym(final String name, int offset) {
        final int length = name.length();
        while (offset < length) {
            final int c = name.codePointAt(offset);
            if (Character.isLowerCase(c)) {
                return false;
            }
            offset += Character.charCount(c);
        }
        return true;
    }

    /**
     * Removes the {@code "get"} or {@code "is"} prefix and turn the first character after the
     * prefix into lower case. For example, the method name {@code "getTitle"} will be replaced
     * by the property name {@code "title"}. We will perform this operation only if there is
     * at least 1 character after the prefix.
     *
     * @param  name  the method name (cannot be {@code null}).
     * @param  base  must be the result of {@code prefix(name).length()}.
     * @return the property name (never {@code null}).
     */
    static String toPropertyName(String name, final int base) {
        final int length = name.length();
        if (length > base) {
            if (isAcronym(name, base)) {
                name = name.substring(base);
            } else {
                final int up = name.codePointAt(base);
                final int lo = Character.toLowerCase(up);
                if (up != lo) {
                    name = new StringBuilder(length - base).appendCodePoint(lo)
                            .append(name, base + Character.charCount(up), length).toString();
                } else {
                    name = name.substring(base);
                }
            }
        }
        return name.intern();
    }
}
