/*
 * Copyright (c) 2016, 2018 Oracle and/or its affiliates. All rights reserved.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0, which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the
 * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
 * version 2 with the GNU Classpath Exception, which is available at
 * https://www.gnu.org/software/classpath/license.html.
 *
 * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
 */

package org.glassfish.hk2.utilities.general.internal;

import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.TreeSet;
import java.util.WeakHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import jakarta.validation.MessageInterpolator;
import jakarta.validation.Payload;
import jakarta.validation.metadata.ConstraintDescriptor;

/**
 * Custom MessageInterpolatorImpl for HK2
 * <p>
 * This message interpolator is different from the default one in the following
 * ways:
 * <ol>
 * <li> It uses a class specified in the "payload" argument to the annotation to
 * find the class loader to find the resource bundle for messages. This allows
 * classes that are in OSGi modules to specify a resource bundle for bean
 * validation messages.</li>
 *
 * <li> The "LocalStrings" resource bundle within the same package as the payload
 * class is used to search for messages.  If a message is not found, the message
 * is obtained from the default bean validation resource bundle.</li>
 * </ol>
 * This class borrows heavily from the RI implementation of the ResourcBundleMessageInterpolator
 * authored by Emmanuel Bernard, Hardy Ferentschik, and Gunnar Morling.
 */
public class MessageInterpolatorImpl implements MessageInterpolator {

    /**
     * The name of the default message bundle.
     */
    public static final String DEFAULT_VALIDATION_MESSAGES = "org.hibernate.validator.ValidationMessages";
    /**
     * The name of the user-provided message bundle as defined in the specification.
     */
    public static final String USER_VALIDATION_MESSAGES = "ValidationMessages";
    /**
     * Regular expression used to do message interpolation.
     */
    private static final Pattern MESSAGE_PARAMETER_PATTERN = Pattern.compile("(\\{[^\\}]+?\\})");
    /**
     * The default locale for the current user.
     */
    private final Locale defaultLocale = Locale.getDefault();
    /**
     * Step 1-3 of message interpolation can be cached. We do this in this map.
     */
    private final Map<LocalisedMessage, String> resolvedMessages = new WeakHashMap<LocalisedMessage, String>();
    /**
     * Flag indicating whether this interpolator should chance some of the interpolation steps.
     */
    private final boolean cacheMessages = true;

    public MessageInterpolatorImpl() { }

    @Override
    public String interpolate(String message, Context context) {
        // probably no need for caching, but it could be done by parameters since the map
        // is immutable and uniquely built per Validation definition, the comparison has to be based on == and not equals though
        return interpolate(message, context, defaultLocale);
    }

    /**
     * Runs the message interpolation according to algorithm specified in JSR 303.
     * <br/>
     * Note:
     * <br/>
     * Look-ups in user bundles is recursive whereas look-ups in default bundle are not!
     *
     * @param message the message to interpolate
     * @param context the context of the annotation which holds the parameters
     * for which to interpolate this message
     * @param locale the {@code Locale} to use for the resource bundle.
     *
     * @return the interpolated message.
     */
    @Override
    public String interpolate(String message, Context context, Locale locale) {
        Map<String, Object> annotationParameters = context.getConstraintDescriptor().getAttributes();
        LocalisedMessage localisedMessage = new LocalisedMessage(message, locale);
        String resolvedMessage = null;

        if (cacheMessages) {
            resolvedMessage = resolvedMessages.get(localisedMessage);
        }

        // if the message is not already in the cache we have to run step 1-3 of the message resolution
        if (resolvedMessage == null) {
            ResourceBundle userResourceBundle = new ContextResourceBundle(context, locale);
            ClassLoader cl;
            if (System.getSecurityManager()!=null) {
                cl = AccessController.doPrivileged(new PrivilegedAction<ClassLoader>() {
                    @Override
                    public ClassLoader run() {
                        return MessageInterpolator.class.getClassLoader();
                    }
                });
            } else {
                cl = MessageInterpolator.class.getClassLoader();
            }
            ResourceBundle defaultResourceBundle = ResourceBundle.getBundle(DEFAULT_VALIDATION_MESSAGES,
                    locale,
                    cl);

            String userBundleResolvedMessage;
            resolvedMessage = message;
            boolean evaluatedDefaultBundleOnce = false;
            do {
                // search the user bundle recursive (step1)
                userBundleResolvedMessage = replaceVariables(
                        resolvedMessage, userResourceBundle, locale, true);

                // exit condition - we have at least tried to validate against the default bundle and there was no
                // further replacements
                if (evaluatedDefaultBundleOnce
                        && !hasReplacementTakenPlace(userBundleResolvedMessage, resolvedMessage)) {
                    break;
                }

                // search the default bundle non recursive (step2)
                resolvedMessage = replaceVariables(userBundleResolvedMessage, defaultResourceBundle, locale, false);
                evaluatedDefaultBundleOnce = true;
                if (cacheMessages) {
                    resolvedMessages.put(localisedMessage, resolvedMessage);
                }
            } while (true);
        }

        // resolve annotation attributes (step 4)
        resolvedMessage = replaceAnnotationAttributes(resolvedMessage, annotationParameters);

        // last but not least we have to take care of escaped literals
        resolvedMessage = resolvedMessage.replace("\\{", "{");
        resolvedMessage = resolvedMessage.replace("\\}", "}");
        resolvedMessage = resolvedMessage.replace("\\\\", "\\");
        return resolvedMessage;
    }

    private boolean hasReplacementTakenPlace(String origMessage, String newMessage) {
        return !origMessage.equals(newMessage);
    }

    private String replaceVariables(String message, ResourceBundle bundle, Locale locale, boolean recurse) {
        Matcher matcher = MESSAGE_PARAMETER_PATTERN.matcher(message);
        StringBuffer sb = new StringBuffer();
        String resolvedParameterValue;
        while (matcher.find()) {
            String parameter = matcher.group(1);
            resolvedParameterValue = resolveParameter(
                    parameter, bundle, locale, recurse);

            matcher.appendReplacement(sb, escapeMetaCharacters(resolvedParameterValue));
        }
        matcher.appendTail(sb);
        return sb.toString();
    }

    private String replaceAnnotationAttributes(String message, Map<String, Object> annotationParameters) {
        Matcher matcher = MESSAGE_PARAMETER_PATTERN.matcher(message);
        StringBuffer sb = new StringBuffer();
        while (matcher.find()) {
            String resolvedParameterValue;
            String parameter = matcher.group(1);
            Object variable = annotationParameters.get(removeCurlyBrace(parameter));
            if (variable != null) {
                resolvedParameterValue = escapeMetaCharacters(variable.toString());
            } else {
                resolvedParameterValue = parameter;
            }
            matcher.appendReplacement(sb, resolvedParameterValue);
        }
        matcher.appendTail(sb);
        return sb.toString();
    }

    private String resolveParameter(String parameterName, ResourceBundle bundle, Locale locale, boolean recurse) {
        String parameterValue;
        try {
            if (bundle != null) {
                parameterValue = bundle.getString(removeCurlyBrace(parameterName));
                if (recurse) {
                    parameterValue = replaceVariables(parameterValue, bundle, locale, recurse);
                }
            } else {
                parameterValue = parameterName;
            }
        } catch (MissingResourceException e) {
            // return parameter itself
            parameterValue = parameterName;
        }
        return parameterValue;
    }

    private String removeCurlyBrace(String parameter) {
        return parameter.substring(1, parameter.length() - 1);
    }

    /**
     * @param s The string in which to replace the meta characters '$' and '\'.
     *
     * @return A string where meta characters relevant for {@link Matcher#appendReplacement} are escaped.
     */
    private String escapeMetaCharacters(String s) {
        String escapedString = s.replace("\\", "\\\\");
        escapedString = escapedString.replace("$", "\\$");
        return escapedString;
    }

    /**
     * A resource bundle that takes strings from the annotation context. This class
     * looks for the "LocalStrings" resource bundle in the same package as the
     * class that is specified in the payload of the annotation. If a String
     * cannot be found there, then it looks in the user resource bundle as defined
     * in JSR-303.
     */
    private static class ContextResourceBundle extends ResourceBundle {
        ResourceBundle contextBundle;
        ResourceBundle userBundle;

        ContextResourceBundle(Context context, Locale locale) {
            ConstraintDescriptor<?> descriptor = context.getConstraintDescriptor();
            Set<Class<? extends Payload>> payload = descriptor.getPayload();

            // Load the LogStrings.properties for the argument Locale, from the ClassLoader
            // of the payload.
            if (!payload.isEmpty()) {
                final Class<?> payloadClass = payload.iterator().next();
                String baseName = payloadClass.getPackage().getName() + ".LocalStrings";
                ClassLoader cl;
                if (System.getSecurityManager()!=null) {
                    cl = AccessController.doPrivileged(new PrivilegedAction<ClassLoader>() {
                        @Override
                        public ClassLoader run() {
                            return payloadClass.getClassLoader();
                        }
                    });
                } else {
                    cl = payloadClass.getClassLoader();
                }

                try {
                    contextBundle = ResourceBundle.getBundle(baseName, locale, cl);
                } catch (MissingResourceException mre) {
                    contextBundle = null;
                }
            }
            try {
                ClassLoader cl = System.getSecurityManager()==null?Thread.currentThread().getContextClassLoader():
                        AccessController.doPrivileged(new PrivilegedAction<ClassLoader>() {
                            @Override
                            public ClassLoader run() {
                                return Thread.currentThread().getContextClassLoader();
                            }
                        });
                userBundle = ResourceBundle.getBundle(USER_VALIDATION_MESSAGES, 
                        locale,
                        cl);
            } catch (MissingResourceException mre) {
                    userBundle = null;
            }

            if (userBundle != null) {
                setParent(userBundle);
            }
        }

        @Override
        protected Object handleGetObject(String key) {
            if (contextBundle != null) {
                return contextBundle.getObject(key);
            }
            return null;
        }

        @Override
        public Enumeration<String> getKeys() {
            Set<String> keys = new TreeSet<String>();
            if (contextBundle != null) {
                keys.addAll(Collections.list(contextBundle.getKeys()));
            }
            if (userBundle != null) {
                keys.addAll(Collections.list(userBundle.getKeys()));
            }
            return Collections.enumeration(keys);
        }
    }

    private static class LocalisedMessage {

        private final String message;
        private final Locale locale;

        LocalisedMessage(String message, Locale locale) {
            this.message = message;
            this.locale = locale;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }

            LocalisedMessage that = (LocalisedMessage) o;

            if (locale != null ? !locale.equals(that.locale) : that.locale != null) {
                return false;
            }
            if (message != null ? !message.equals(that.message) : that.message != null) {
                return false;
            }

            return true;
        }

        @Override
        public int hashCode() {
            int result = message != null ? message.hashCode() : 0;
            result = 31 * result + (locale != null ? locale.hashCode() : 0);
            return result;
        }
    }
}
