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

import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

/**
 * URI utilities.
 *
 * @version 
 */
public final class URISupport {

    public static final String RAW_TOKEN_START = "RAW(";
    public static final String RAW_TOKEN_END = ")";

    // Match any key-value pair in the URI query string whose key contains
    // "passphrase" or "password" or secret key (case-insensitive).
    // First capture group is the key, second is the value.
    private static final Pattern SECRETS = Pattern.compile("([?&][^=]*(?:passphrase|password|secretKey)[^=]*)=(RAW\\(.*\\)|[^&]*)",
            Pattern.CASE_INSENSITIVE);
    
    // Match the user password in the URI as second capture group
    // (applies to URI with authority component and userinfo token in the form "user:password").
    private static final Pattern USERINFO_PASSWORD = Pattern.compile("(.*://.*:)(.*)(@)");
    
    // Match the user password in the URI path as second capture group
    // (applies to URI path with authority component and userinfo token in the form "user:password").
    private static final Pattern PATH_USERINFO_PASSWORD = Pattern.compile("(.*:)(.*)(@)");
    
    private static final String CHARSET = "UTF-8";

    private URISupport() {
        // Helper class
    }

    /**
     * Removes detected sensitive information (such as passwords) from the URI and returns the result.
     *
     * @param uri The uri to sanitize.
     * @see #SECRETS and #USERINFO_PASSWORD for the matched pattern
     *
     * @return Returns null if the uri is null, otherwise the URI with the passphrase, password or secretKey sanitized.
     */
    public static String sanitizeUri(String uri) {
        // use xxxxx as replacement as that works well with JMX also
        String sanitized = uri;
        if (uri != null) {
            sanitized = SECRETS.matcher(sanitized).replaceAll("$1=xxxxxx");
            sanitized = USERINFO_PASSWORD.matcher(sanitized).replaceFirst("$1xxxxxx$3");
        }
        return sanitized;
    }
    
    /**
     * Removes detected sensitive information (such as passwords) from the
     * <em>path part</em> of an URI (that is, the part without the query
     * parameters or component prefix) and returns the result.
     * 
     * @param path the URI path to sanitize
     * @return null if the path is null, otherwise the sanitized path
     */
    public static String sanitizePath(String path) {
        String sanitized = path;
        if (path != null) {
            sanitized = PATH_USERINFO_PASSWORD.matcher(sanitized).replaceFirst("$1xxxxxx$3");
        }
        return sanitized;
    }

    /**
     * Extracts the scheme specific path from the URI that is used as the remainder option when creating endpoints.
     *
     * @param u      the URI
     * @param useRaw whether to force using raw values
     * @return the remainder path
     */
    public static String extractRemainderPath(URI u, boolean useRaw) {
        String path = useRaw ? u.getRawSchemeSpecificPart() : u.getSchemeSpecificPart();

        // lets trim off any query arguments
        if (path.startsWith("//")) {
            path = path.substring(2);
        }
        int idx = path.indexOf('?');
        if (idx > -1) {
            path = path.substring(0, idx);
        }

        return path;
    }

    /**
     * Parses the query part of the uri (eg the parameters).
     * <p/>
     * The URI parameters will by default be URI encoded. However you can define a parameter
     * values with the syntax: <tt>key=RAW(value)</tt> which tells Camel to not encode the value,
     * and use the value as is (eg key=value) and the value has <b>not</b> been encoded.
     *
     * @param uri the uri
     * @return the parameters, or an empty map if no parameters (eg never null)
     * @throws URISyntaxException is thrown if uri has invalid syntax.
     * @see #RAW_TOKEN_START
     * @see #RAW_TOKEN_END
     */
    public static Map<String, Object> parseQuery(String uri) throws URISyntaxException {
        return parseQuery(uri, false);
    }

    /**
     * Parses the query part of the uri (eg the parameters).
     * <p/>
     * The URI parameters will by default be URI encoded. However you can define a parameter
     * values with the syntax: <tt>key=RAW(value)</tt> which tells Camel to not encode the value,
     * and use the value as is (eg key=value) and the value has <b>not</b> been encoded.
     *
     * @param uri the uri
     * @param useRaw whether to force using raw values
     * @return the parameters, or an empty map if no parameters (eg never null)
     * @throws URISyntaxException is thrown if uri has invalid syntax.
     * @see #RAW_TOKEN_START
     * @see #RAW_TOKEN_END
     */
    public static Map<String, Object> parseQuery(String uri, boolean useRaw) throws URISyntaxException {
        return parseQuery(uri, useRaw, false);
    }

    /**
     * Parses the query part of the uri (eg the parameters).
     * <p/>
     * The URI parameters will by default be URI encoded. However you can define a parameter
     * values with the syntax: <tt>key=RAW(value)</tt> which tells Camel to not encode the value,
     * and use the value as is (eg key=value) and the value has <b>not</b> been encoded.
     *
     * @param uri the uri
     * @param useRaw whether to force using raw values
     * @param lenient whether to parse lenient and ignore trailing & markers which has no key or value which can happen when using HTTP components
     * @return the parameters, or an empty map if no parameters (eg never null)
     * @throws URISyntaxException is thrown if uri has invalid syntax.
     * @see #RAW_TOKEN_START
     * @see #RAW_TOKEN_END
     */
    public static Map<String, Object> parseQuery(String uri, boolean useRaw, boolean lenient) throws URISyntaxException {
        // must check for trailing & as the uri.split("&") will ignore those
        if (!lenient) {
            if (uri != null && uri.endsWith("&")) {
                throw new URISyntaxException(uri, "Invalid uri syntax: Trailing & marker found. "
                        + "Check the uri and remove the trailing & marker.");
            }
        }

        if (uri == null || ObjectHelper.isEmpty(uri)) {
            // return an empty map
            return new LinkedHashMap<String, Object>(0);
        }

        // need to parse the uri query parameters manually as we cannot rely on splitting by &,
        // as & can be used in a parameter value as well.

        try {
            // use a linked map so the parameters is in the same order
            Map<String, Object> rc = new LinkedHashMap<String, Object>();

            boolean isKey = true;
            boolean isValue = false;
            boolean isRaw = false;
            StringBuilder key = new StringBuilder();
            StringBuilder value = new StringBuilder();

            // parse the uri parameters char by char
            for (int i = 0; i < uri.length(); i++) {
                // current char
                char ch = uri.charAt(i);
                // look ahead of the next char
                char next;
                if (i <= uri.length() - 2) {
                    next = uri.charAt(i + 1);
                } else {
                    next = '\u0000';
                }

                // are we a raw value
                isRaw = value.toString().startsWith(RAW_TOKEN_START);

                // if we are in raw mode, then we keep adding until we hit the end marker
                if (isRaw) {
                    if (isKey) {
                        key.append(ch);
                    } else if (isValue) {
                        value.append(ch);
                    }

                    // we only end the raw marker if its )& or at the end of the value

                    boolean end = ch == RAW_TOKEN_END.charAt(0) && (next == '&' || next == '\u0000');
                    if (end) {
                        // raw value end, so add that as a parameter, and reset flags
                        addParameter(key.toString(), value.toString(), rc, useRaw || isRaw);
                        key.setLength(0);
                        value.setLength(0);
                        isKey = true;
                        isValue = false;
                        isRaw = false;
                        // skip to next as we are in raw mode and have already added the value
                        i++;
                    }
                    continue;
                }

                // if its a key and there is a = sign then the key ends and we are in value mode
                if (isKey && ch == '=') {
                    isKey = false;
                    isValue = true;
                    isRaw = false;
                    continue;
                }

                // the & denote parameter is ended
                if (ch == '&') {
                    // parameter is ended, as we hit & separator
                    addParameter(key.toString(), value.toString(), rc, useRaw || isRaw);
                    key.setLength(0);
                    value.setLength(0);
                    isKey = true;
                    isValue = false;
                    isRaw = false;
                    continue;
                }

                // regular char so add it to the key or value
                if (isKey) {
                    key.append(ch);
                } else if (isValue) {
                    value.append(ch);
                }
            }

            // any left over parameters, then add that
            if (key.length() > 0) {
                addParameter(key.toString(), value.toString(), rc, useRaw || isRaw);
            }

            return rc;

        } catch (UnsupportedEncodingException e) {
            URISyntaxException se = new URISyntaxException(e.toString(), "Invalid encoding");
            se.initCause(e);
            throw se;
        }
    }

    private static void addParameter(String name, String value, Map<String, Object> map, boolean isRaw) throws UnsupportedEncodingException {
        name = URLDecoder.decode(name, CHARSET);
        if (!isRaw) {
            // need to replace % with %25
            String s = StringHelper.replaceAll(value, "%", "%25");
            value = URLDecoder.decode(s, CHARSET);
        }

        // does the key already exist?
        if (map.containsKey(name)) {
            // yes it does, so make sure we can support multiple values, but using a list
            // to hold the multiple values
            Object existing = map.get(name);
            List<String> list;
            if (existing instanceof List) {
                list = CastUtils.cast((List<?>) existing);
            } else {
                // create a new list to hold the multiple values
                list = new ArrayList<String>();
                String s = existing != null ? existing.toString() : null;
                if (s != null) {
                    list.add(s);
                }
            }
            list.add(value);
            map.put(name, list);
        } else {
            map.put(name, value);
        }
    }

    /**
     * Parses the query parameters of the uri (eg the query part).
     *
     * @param uri the uri
     * @return the parameters, or an empty map if no parameters (eg never null)
     * @throws URISyntaxException is thrown if uri has invalid syntax.
     */
    public static Map<String, Object> parseParameters(URI uri) throws URISyntaxException {
        String query = uri.getQuery();
        if (query == null) {
            String schemeSpecificPart = uri.getSchemeSpecificPart();
            int idx = schemeSpecificPart.indexOf('?');
            if (idx < 0) {
                // return an empty map
                return new LinkedHashMap<String, Object>(0);
            } else {
                query = schemeSpecificPart.substring(idx + 1);
            }
        } else {
            query = stripPrefix(query, "?");
        }
        return parseQuery(query);
    }

    /**
     * Traverses the given parameters, and resolve any parameter values which uses the RAW token
     * syntax: <tt>key=RAW(value)</tt>. This method will then remove the RAW tokens, and replace
     * the content of the value, with just the value.
     *
     * @param parameters the uri parameters
     * @see #parseQuery(String)
     * @see #RAW_TOKEN_START
     * @see #RAW_TOKEN_END
     */
    @SuppressWarnings("unchecked")
    public static void resolveRawParameterValues(Map<String, Object> parameters) {
        for (Map.Entry<String, Object> entry : parameters.entrySet()) {
            if (entry.getValue() != null) {
                // if the value is a list then we need to iterate
                Object value = entry.getValue();
                if (value instanceof List) {
                    List list = (List) value;
                    for (int i = 0; i < list.size(); i++) {
                        Object obj = list.get(i);
                        if (obj != null) {
                            String str = obj.toString();
                            if (str.startsWith(RAW_TOKEN_START) && str.endsWith(RAW_TOKEN_END)) {
                                str = str.substring(4, str.length() - 1);
                                // update the string in the list
                                list.set(i, str);
                            }
                        }
                    }
                } else {
                    String str = entry.getValue().toString();
                    if (str.startsWith(RAW_TOKEN_START) && str.endsWith(RAW_TOKEN_END)) {
                        str = str.substring(4, str.length() - 1);
                        entry.setValue(str);
                    }
                }
            }
        }
    }

    /**
     * Creates a URI with the given query
     *
     * @param uri the uri
     * @param query the query to append to the uri
     * @return uri with the query appended
     * @throws URISyntaxException is thrown if uri has invalid syntax.
     */
    public static URI createURIWithQuery(URI uri, String query) throws URISyntaxException {
        ObjectHelper.notNull(uri, "uri");

        // assemble string as new uri and replace parameters with the query instead
        String s = uri.toString();
        String before = ObjectHelper.before(s, "?");
        if (before == null) {
            before = ObjectHelper.before(s, "#");
        }
        if (before != null) {
            s = before;
        }
        if (query != null) {
            s = s + "?" + query;
        }
        if ((!s.contains("#")) && (uri.getFragment() != null)) {
            s = s + "#" + uri.getFragment();
        }

        return new URI(s);
    }

    /**
     * Strips the prefix from the value.
     * <p/>
     * Returns the value as-is if not starting with the prefix.
     *
     * @param value  the value
     * @param prefix the prefix to remove from value
     * @return the value without the prefix
     */
    public static String stripPrefix(String value, String prefix) {
        if (value != null && value.startsWith(prefix)) {
            return value.substring(prefix.length());
        }
        return value;
    }

    /**
     * Assembles a query from the given map.
     *
     * @param options  the map with the options (eg key/value pairs)
     * @return a query string with <tt>key1=value&key2=value2&...</tt>, or an empty string if there is no options.
     * @throws URISyntaxException is thrown if uri has invalid syntax.
     */
    @SuppressWarnings("unchecked")
    public static String createQueryString(Map<String, Object> options) throws URISyntaxException {
        try {
            if (options.size() > 0) {
                StringBuilder rc = new StringBuilder();
                boolean first = true;
                for (Object o : options.keySet()) {
                    if (first) {
                        first = false;
                    } else {
                        rc.append("&");
                    }

                    String key = (String) o;
                    Object value = options.get(key);

                    // the value may be a list since the same key has multiple values
                    if (value instanceof List) {
                        List<String> list = (List<String>) value;
                        for (Iterator<String> it = list.iterator(); it.hasNext();) {
                            String s = it.next();
                            appendQueryStringParameter(key, s, rc);
                            // append & separator if there is more in the list to append
                            if (it.hasNext()) {
                                rc.append("&");
                            }
                        }
                    } else {
                        // use the value as a String
                        String s = value != null ? value.toString() : null;
                        appendQueryStringParameter(key, s, rc);
                    }
                }
                return rc.toString();
            } else {
                return "";
            }
        } catch (UnsupportedEncodingException e) {
            URISyntaxException se = new URISyntaxException(e.toString(), "Invalid encoding");
            se.initCause(e);
            throw se;
        }
    }

    private static void appendQueryStringParameter(String key, String value, StringBuilder rc) throws UnsupportedEncodingException {
        rc.append(URLEncoder.encode(key, CHARSET));
        // only append if value is not null
        if (value != null) {
            rc.append("=");
            if (value.startsWith(RAW_TOKEN_START) && value.endsWith(RAW_TOKEN_END)) {
                // do not encode RAW parameters unless it has %
                // need to replace % with %25 to avoid losing "%" when decoding
                String s = StringHelper.replaceAll(value, "%", "%25");
                rc.append(s);
            } else {
                rc.append(URLEncoder.encode(value, CHARSET));
            }
        }
    }

    /**
     * Creates a URI from the original URI and the remaining parameters
     * <p/>
     * Used by various Camel components
     */
    public static URI createRemainingURI(URI originalURI, Map<String, Object> params) throws URISyntaxException {
        String s = createQueryString(params);
        if (s.length() == 0) {
            s = null;
        }
        return createURIWithQuery(originalURI, s);
    }

    /**
     * Appends the given parameters to the given URI.
     * <p/>
     * It keeps the original parameters and if a new parameter is already defined in
     * {@code originalURI}, it will be replaced by its value in {@code newParameters}.
     *
     * @param originalURI   the original URI
     * @param newParameters the parameters to add
     * @return the URI with all the parameters
     * @throws URISyntaxException           is thrown if the uri syntax is invalid
     * @throws UnsupportedEncodingException is thrown if encoding error
     */
    public static String appendParametersToURI(String originalURI, Map<String, Object> newParameters) throws URISyntaxException, UnsupportedEncodingException {
        URI uri = new URI(normalizeUri(originalURI));
        Map<String, Object> parameters = parseParameters(uri);
        parameters.putAll(newParameters);
        return createRemainingURI(uri, parameters).toString();
    }

    /**
     * Normalizes the uri by reordering the parameters so they are sorted and thus
     * we can use the uris for endpoint matching.
     * <p/>
     * The URI parameters will by default be URI encoded. However you can define a parameter
     * values with the syntax: <tt>key=RAW(value)</tt> which tells Camel to not encode the value,
     * and use the value as is (eg key=value) and the value has <b>not</b> been encoded.
     *
     * @param uri the uri
     * @return the normalized uri
     * @throws URISyntaxException in thrown if the uri syntax is invalid
     * @throws UnsupportedEncodingException is thrown if encoding error
     * @see #RAW_TOKEN_START
     * @see #RAW_TOKEN_END
     */
    public static String normalizeUri(String uri) throws URISyntaxException, UnsupportedEncodingException {

        URI u = new URI(UnsafeUriCharactersEncoder.encode(uri, true));
        String path = u.getSchemeSpecificPart();
        String scheme = u.getScheme();

        // not possible to normalize
        if (scheme == null || path == null) {
            return uri;
        }

        // lets trim off any query arguments
        if (path.startsWith("//")) {
            path = path.substring(2);
        }
        int idx = path.indexOf('?');
        // when the path has ?
        if (idx != -1) {
            path = path.substring(0, idx);
        }

        if (u.getScheme().startsWith("http")) {
            path = UnsafeUriCharactersEncoder.encodeHttpURI(path);
        } else {
            path = UnsafeUriCharactersEncoder.encode(path);
        }

        // okay if we have user info in the path and they use @ in username or password,
        // then we need to encode them (but leave the last @ sign before the hostname)
        // this is needed as Camel end users may not encode their user info properly, but expect
        // this to work out of the box with Camel, and hence we need to fix it for them
        String userInfoPath = path;
        if (userInfoPath.contains("/")) {
            userInfoPath = userInfoPath.substring(0, userInfoPath.indexOf("/"));
        }
        if (StringHelper.countChar(userInfoPath, '@') > 1) {
            int max = userInfoPath.lastIndexOf('@');
            String before = userInfoPath.substring(0, max);
            // after must be from original path
            String after = path.substring(max);

            // replace the @ with %40
            before = StringHelper.replaceAll(before, "@", "%40");
            path = before + after;
        }

        // in case there are parameters we should reorder them
        Map<String, Object> parameters = URISupport.parseParameters(u);
        if (parameters.isEmpty()) {
            // no parameters then just return
            return buildUri(scheme, path, null);
        } else {
            // reorder parameters a..z
            List<String> keys = new ArrayList<String>(parameters.keySet());
            Collections.sort(keys);

            Map<String, Object> sorted = new LinkedHashMap<String, Object>(parameters.size());
            for (String key : keys) {
                sorted.put(key, parameters.get(key));
            }

            // build uri object with sorted parameters
            String query = URISupport.createQueryString(sorted);
            return buildUri(scheme, path, query);
        }
    }

    private static String buildUri(String scheme, String path, String query) {
        // must include :// to do a correct URI all components can work with
        return scheme + "://" + path + (query != null ? "?" + query : "");
    }
}
