/*
 * Copyright (c) 2016, 2018, Oracle and/or its affiliates. All rights reserved.
 *
 * This program is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, version 2.0, as published by the
 * Free Software Foundation.
 *
 * This program is also distributed with certain software (including but not
 * limited to OpenSSL) that is licensed under separate terms, as designated in a
 * particular file or component or in included license documentation. The
 * authors of MySQL hereby grant you an additional permission to link the
 * program and your derivative works with the separately licensed software that
 * they have included with MySQL.
 *
 * Without limiting anything contained in the foregoing, this file, which is
 * part of MySQL Connector/J, is also subject to the Universal FOSS Exception,
 * version 1.0, a copy of which can be found at
 * http://oss.oracle.com/licenses/universal-foss-exception.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU General Public License, version 2.0,
 * for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
 */

package com.mysql.cj.conf;

import static com.mysql.cj.util.StringUtils.isNullOrEmpty;
import static com.mysql.cj.util.StringUtils.safeTrim;

import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import com.mysql.cj.Messages;
import com.mysql.cj.conf.ConnectionUrl.Type;
import com.mysql.cj.exceptions.ExceptionFactory;
import com.mysql.cj.exceptions.UnsupportedConnectionStringException;
import com.mysql.cj.exceptions.WrongArgumentException;
import com.mysql.cj.util.StringUtils;

/**
 * This class parses a connection string using the general URI structure defined in RFC 3986. Instead of using a URI instance to ensure the correct syntax of
 * the connection string, this implementation uses regular expressions which is faster but also less strict in terms of validations. This actually works better
 * because database URLs don't exactly stick to the RFC 3986 rules.
 * <p>
 * <i>scheme://authority/path?query#fragment</i>
 * <p>
 * This results in splitting the connection string URL and processing its internal parts:
 * <dl>
 * <dt>scheme</dt>
 * <dd>The protocol and subprotocol identification. Usually "jdbc:mysql:" or "mysqlx:".</dd>
 * <dt>authority</dt>
 * <dd>Contains information about the user credentials and/or the host and port information. Unlike its definition in the RFC 3986 specification, there can be
 * multiple authority sections separated by a single comma (,) in a connection string. It is also possible to use an alternative syntax for the user and/or host
 * identification, that also allows setting per host connection properties, in the form of
 * "[user[:password]@]address=(key1=value)[(key2=value)]...[,address=(key3=value)[(key4=value)]...]...".</dd>
 * <dt>path</dt>
 * <dd>Corresponds to the database identification.</dd>
 * <dt>query</dt>
 * <dd>The connection properties, written as "propertyName1[=[propertyValue1]][&amp;propertyName2[=[propertyValue2]]]..."</dd>
 * <dt>fragment</dt>
 * <dd>The fragment section is ignored in Connector/J connection strings.</dd>
 * </dl>
 */
public class ConnectionUrlParser implements DatabaseUrlContainer {
    private static final String DUMMY_SCHEMA = "cj://";
    private static final String USER_PASS_SEPARATOR = ":";
    private static final String USER_HOST_SEPARATOR = "@";
    private static final String HOSTS_SEPARATOR = ",";
    private static final String KEY_VALUE_HOST_INFO_OPENING_MARKER = "(";
    private static final String KEY_VALUE_HOST_INFO_CLOSING_MARKER = ")";
    private static final String HOSTS_LIST_OPENING_MARKERS = "[(";
    private static final String HOSTS_LIST_CLOSING_MARKERS = "])";
    private static final String ADDRESS_EQUALS_HOST_INFO_PREFIX = "ADDRESS=";

    private static final Pattern CONNECTION_STRING_PTRN = Pattern.compile("(?<scheme>[\\w:%]+)\\s*" // scheme: required; alphanumeric, colon or percent
            + "(?://(?<authority>[^/?#]*))?\\s*" // authority: optional; starts with "//" followed by any char except "/", "?" and "#"
            + "(?:/(?!\\s*/)(?<path>[^?#]*))?" // path: optional; starts with "/" but not followed by "/", and then followed by by any char except "?" and "#"
            + "(?:\\?(?!\\s*\\?)(?<query>[^#]*))?" // query: optional; starts with "?" but not followed by "?", and then followed by by any char except "#"
            + "(?:\\s*#(?<fragment>.*))?"); // fragment: optional; starts with "#", and then followed by anything
    private static final Pattern SCHEME_PTRN = Pattern.compile("(?<scheme>[\\w:%]+).*");
    private static final Pattern HOST_LIST_PTRN = Pattern.compile("^\\[(?<hosts>.*)\\]$");
    private static final Pattern GENERIC_HOST_PTRN = Pattern.compile("^(?<host>.*?)(?::(?<port>[^:]*))?$");
    private static final Pattern KEY_VALUE_HOST_PTRN = Pattern.compile("[,\\s]*(?<key>[\\w\\.\\-\\s%]*)(?:=(?<value>[^,]*))?");
    private static final Pattern ADDRESS_EQUALS_HOST_PTRN = Pattern.compile("\\s*\\(\\s*(?<key>[\\w\\.\\-%]+)?\\s*(?:=(?<value>[^)]*))?\\)\\s*");
    private static final Pattern PROPERTIES_PTRN = Pattern.compile("[&\\s]*(?<key>[\\w\\.\\-\\s%]*)(?:=(?<value>[^&]*))?");

    private final String baseConnectionString;
    private String scheme;
    private String authority;
    private String path;
    private String query;

    private List<HostInfo> parsedHosts = null;
    private Map<String, String> parsedProperties = null;

    /**
     * Static factory method for constructing instances of this class.
     * 
     * @param connString
     *            The connection string to parse.
     * @return an instance of {@link ConnectionUrlParser}
     */
    public static ConnectionUrlParser parseConnectionString(String connString) {
        return new ConnectionUrlParser(connString);
    }

    /**
     * Constructs a connection string parser for the given connection string.
     * 
     * @param connString
     *            the connection string to parse
     */
    private ConnectionUrlParser(String connString) {
        if (connString == null) {
            throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.0"));
        }
        if (!isConnectionStringSupported(connString)) {
            throw ExceptionFactory.createException(UnsupportedConnectionStringException.class,
                    Messages.getString("ConnectionString.17", new String[] { connString }));
        }
        this.baseConnectionString = connString;
        parseConnectionString();
    }

    /**
     * Checks if the scheme part of given connection string matches one of the {@link Type}s supported by Connector/J.
     * Throws {@link WrongArgumentException} if connString is null.
     * 
     * @param connString
     *            connection string
     * @return true if supported
     */
    public static boolean isConnectionStringSupported(String connString) {
        if (connString == null) {
            throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.0"));
        }
        Matcher matcher = SCHEME_PTRN.matcher(connString);
        return matcher.matches() && Type.isSupported(decode(matcher.group("scheme")));
    }

    /**
     * Splits the connection string in its main sections.
     */
    private void parseConnectionString() {
        String connString = this.baseConnectionString;
        Matcher matcher = CONNECTION_STRING_PTRN.matcher(connString);
        if (!matcher.matches()) {
            throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.1"));
        }
        this.scheme = decode(matcher.group("scheme"));
        this.authority = matcher.group("authority"); // Don't decode just yet.
        this.path = matcher.group("path") == null ? null : decode(matcher.group("path")).trim();
        this.query = matcher.group("query"); // Don't decode just yet.
    }

    /**
     * Parses the authority section (user and/or host identification) of the connection string URI.
     */
    private void parseAuthoritySection() {
        if (isNullOrEmpty(this.authority)) {
            // Add an empty, default, host.
            this.parsedHosts.add(new HostInfo());
            return;
        }

        List<String> authoritySegments = StringUtils.split(this.authority, HOSTS_SEPARATOR, HOSTS_LIST_OPENING_MARKERS, HOSTS_LIST_CLOSING_MARKERS, true,
                StringUtils.SEARCH_MODE__MRK_WS);
        for (String hi : authoritySegments) {
            parseAuthoritySegment(hi);
        }
    }

    /**
     * Parses the given sub authority segment, which can take one of the following syntaxes:
     * <ul>
     * <li>_user_:[email protected]_host_:_port_
     * <li>_user_:[email protected](key1=value1,key2=value2,...)
     * <li>_user_:[email protected]=(key1=value1)(key2=value2)...
     * <li>_user_:[email protected][_any_of_the_above_1_,_any_of_the_above_2_,...]
     * </ul>
     * Most of the above placeholders can be omitted, representing a null, empty, or default value.
     * The placeholder _host_, can be a host name, IPv4 or IPv6. This parser doesn't check IP syntax. IPv6 addresses are enclosed by square brackets ([::1]).
     * The placeholder _any_of_the_above_?_ can be any of the above except for the user information part (_user_:[email protected]).
     * When the symbol ":" is not used, it means an null/empty password or a default (-1) port, respectively.
     * When the symbol "@" is not used, it means that the authority part doesn't contain user information (depending on the scheme type can still be provided
     * via key=value pairs).
     * 
     * @param authSegment
     *            the string containing the authority segment
     */
    private void parseAuthoritySegment(String authSegment) {
        /*
         * Start by splitting the user and host information parts from the authority segment and process the user information, if any.
         */
        Pair<String, String> userHostInfoSplit = splitByUserInfoAndHostInfo(authSegment);
        String userInfo = safeTrim(userHostInfoSplit.left);
        String user = null;
        String password = null;
        if (!isNullOrEmpty(userInfo)) {
            Pair<String, String> userInfoPair = parseUserInfo(userInfo);
            user = decode(safeTrim(userInfoPair.left));
            password = decode(safeTrim(userInfoPair.right));
        }
        String hostInfo = safeTrim(userHostInfoSplit.right);

        /*
         * Handle an authority part without host information.
         */
        HostInfo hi = buildHostInfoForEmptyHost(user, password, hostInfo);
        if (hi != null) {
            this.parsedHosts.add(hi);
            return;
        }

        /*
         * Try using a java.net.URI instance to parse the host information. This helps dealing with the IPv6 syntax.
         */
        hi = buildHostInfoResortingToUriParser(user, password, authSegment);
        if (hi != null) {
            this.parsedHosts.add(hi);
            return;
        }

        /*
         * Using a URI didn't work, now check if the host part is composed by a sub list of hosts and process them, one by one if so.
         */
        List<HostInfo> hiList = buildHostInfoResortingToSubHostsListParser(user, password, hostInfo);
        if (hiList != null) {
            this.parsedHosts.addAll(hiList);
            return;
        }

        /*
         * The hosts list syntax didn't work, now check if the host information is written in the alternate syntax "(Key1=value1,key2=value2)".
         */
        hi = buildHostInfoResortingToKeyValueSyntaxParser(user, password, hostInfo);
        if (hi != null) {
            this.parsedHosts.add(hi);
            return;
        }

        /*
         * Key/value syntax didn't work either, now check if the host information is written in the alternate syntax "address=(...)".
         * This parser needs to run after the key/value one because a key named "address" could invalidate it.
         */
        hi = buildHostInfoResortingToAddressEqualsSyntaxParser(user, password, hostInfo);
        if (hi != null) {
            this.parsedHosts.add(hi);
            return;
        }

        /*
         * Alternate syntax also failed, let's wind up the corner cases the URI couldn't handle.
         */
        hi = buildHostInfoResortingToGenericSyntaxParser(user, password, hostInfo);
        if (hi != null) {
            this.parsedHosts.add(hi);
            return;
        }

        /*
         * Failed parsing the authority segment.
         */
        throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.2", new Object[] { authSegment }));
    }

    /**
     * Builds an {@link HostInfo} instance for empty host authority segments.
     * 
     * @param user
     *            the user to include in the final {@link HostInfo}
     * @param password
     *            the password to include in the final {@link HostInfo}
     * @param hostInfo
     *            the string containing the host information part
     * @return the {@link HostInfo} instance containing the parsed information or <code>null</code> if the host part is not empty
     */
    private HostInfo buildHostInfoForEmptyHost(String user, String password, String hostInfo) {
        if (isNullOrEmpty(hostInfo)) {
            if (isNullOrEmpty(user) && isNullOrEmpty(password)) {
                return new HostInfo();
            }
            return new HostInfo(this, null, -1, user, password);
        }
        return null;
    }

    /**
     * Parses the host information resorting to a URI object. This process handles most single-host well formed addresses.
     * 
     * @param user
     *            the user to include in the final {@link HostInfo}
     * @param password
     *            the password to include in the final {@link HostInfo}
     * @param hostInfo
     *            the string containing the host information part
     * 
     * @return the {@link HostInfo} instance containing the parsed information or <code>null</code> if unable to parse the host information
     */
    private HostInfo buildHostInfoResortingToUriParser(String user, String password, String hostInfo) {
        String host = null;
        int port = -1;

        try {
            URI uri = URI.create(DUMMY_SCHEMA + hostInfo);
            if (uri.getHost() != null) {
                host = decode(uri.getHost());
            }
            if (uri.getPort() != -1) {
                port = uri.getPort();
            }
            if (uri.getUserInfo() != null) {
                // Can't have another one. The user information should have been handled already.
                return null;
            }
        } catch (IllegalArgumentException e) {
            // The URI failed to parse the host information.
            return null;
        }
        if (host != null || port != -1) {
            // The host info parsing succeeded.
            return new HostInfo(this, host, port, user, password);
        }
        return null;
    }

    /**
     * Parses the host information using the alternate sub hosts lists syntax "[host1, host2, ...]".
     * 
     * @param user
     *            the user to include in all the resulting {@link HostInfo}
     * @param password
     *            the password to include in all the resulting {@link HostInfo}
     * @param hostInfo
     *            the string containing the host information part
     * @return a list with all {@link HostInfo} instances containing the parsed information or <code>null</code> if unable to parse the host information
     */
    private List<HostInfo> buildHostInfoResortingToSubHostsListParser(String user, String password, String hostInfo) {
        Matcher matcher = HOST_LIST_PTRN.matcher(hostInfo);
        if (matcher.matches()) {
            String hosts = matcher.group("hosts");
            List<String> hostsList = StringUtils.split(hosts, HOSTS_SEPARATOR, HOSTS_LIST_OPENING_MARKERS, HOSTS_LIST_CLOSING_MARKERS, true,
                    StringUtils.SEARCH_MODE__MRK_WS);
            // One single element could, in fact, be an IPv6 stripped from its delimiters.
            boolean maybeIPv6 = hostsList.size() == 1 && hostsList.get(0).matches("(?i)^[\\dabcdef:]+$");
            List<HostInfo> hostInfoList = new ArrayList<>();
            for (String h : hostsList) {
                HostInfo hi;
                if ((hi = buildHostInfoForEmptyHost(user, password, h)) != null) {
                    hostInfoList.add(hi);
                } else if ((hi = buildHostInfoResortingToUriParser(user, password, h)) != null
                        || (maybeIPv6 && (hi = buildHostInfoResortingToUriParser(user, password, "[" + h + "]")) != null)) {
                    hostInfoList.add(hi);
                } else if ((hi = buildHostInfoResortingToKeyValueSyntaxParser(user, password, h)) != null) {
                    hostInfoList.add(hi);
                } else if ((hi = buildHostInfoResortingToAddressEqualsSyntaxParser(user, password, h)) != null) {
                    hostInfoList.add(hi);
                } else if ((hi = buildHostInfoResortingToGenericSyntaxParser(user, password, h)) != null) {
                    hostInfoList.add(hi);
                } else {
                    return null;
                }
            }
            return hostInfoList;
        }
        return null;
    }

    /**
     * Parses the host information using the alternate syntax "(key1=value1, key2=value2, ...)".
     * 
     * @param user
     *            the user to include in the resulting {@link HostInfo}
     * @param password
     *            the password to include in the resulting {@link HostInfo}
     * @param hostInfo
     *            the string containing the host information part
     * @return the {@link HostInfo} instance containing the parsed information or <code>null</code> if unable to parse the host information
     */
    private HostInfo buildHostInfoResortingToKeyValueSyntaxParser(String user, String password, String hostInfo) {
        if (!hostInfo.startsWith(KEY_VALUE_HOST_INFO_OPENING_MARKER) || !hostInfo.endsWith(KEY_VALUE_HOST_INFO_CLOSING_MARKER)) {
            // This pattern won't work.
            return null;
        }
        hostInfo = hostInfo.substring(KEY_VALUE_HOST_INFO_OPENING_MARKER.length(), hostInfo.length() - KEY_VALUE_HOST_INFO_CLOSING_MARKER.length());
        return new HostInfo(this, null, -1, user, password, processKeyValuePattern(KEY_VALUE_HOST_PTRN, hostInfo));
    }

    /**
     * Parses the host information using the alternate syntax "address=(key1=value1)(key2=value2)...".
     * 
     * @param user
     *            the user to include in the resulting {@link HostInfo}
     * @param password
     *            the password to include in the resulting {@link HostInfo}
     * @param hostInfo
     *            the string containing the host information part
     * @return the {@link HostInfo} instance containing the parsed information or <code>null</code> if unable to parse the host information
     */
    private HostInfo buildHostInfoResortingToAddressEqualsSyntaxParser(String user, String password, String hostInfo) {
        int p = StringUtils.indexOfIgnoreCase(hostInfo, ADDRESS_EQUALS_HOST_INFO_PREFIX);
        if (p != 0) {
            // This pattern won't work.
            return null;
        }
        hostInfo = hostInfo.substring(p + ADDRESS_EQUALS_HOST_INFO_PREFIX.length()).trim();
        return new HostInfo(this, null, -1, user, password, processKeyValuePattern(ADDRESS_EQUALS_HOST_PTRN, hostInfo));
    }

    /**
     * Parses the host information using the generic syntax "host:port".
     * 
     * @param user
     *            the user to include in the resulting {@link HostInfo}
     * @param password
     *            the password to include in the resulting {@link HostInfo}
     * @param hostInfo
     *            the string containing the host information part
     * @return the {@link HostInfo} instance containing the parsed information or <code>null</code> if unable to parse the host information
     */
    private HostInfo buildHostInfoResortingToGenericSyntaxParser(String user, String password, String hostInfo) {
        if (splitByUserInfoAndHostInfo(hostInfo).left != null) {
            // This host information is invalid if contains another user information part.
            return null;
        }
        Pair<String, Integer> hostPortPair = parseHostPortPair(hostInfo);
        String host = decode(safeTrim(hostPortPair.left));
        Integer port = hostPortPair.right;
        return new HostInfo(this, isNullOrEmpty(host) ? null : host, port, user, password);
    }

    /**
     * Splits the given authority segment in the user information part and the host part.
     * 
     * @param authSegment
     *            the string containing the authority segment, i.e., the user and host information parts
     * @return
     *         a {@link Pair} containing the user information in the left side and the host information in the right
     */
    private Pair<String, String> splitByUserInfoAndHostInfo(String authSegment) {
        String userInfoPart = null;
        String hostInfoPart = authSegment;
        int p = authSegment.indexOf(USER_HOST_SEPARATOR);
        if (p >= 0) {
            userInfoPart = authSegment.substring(0, p);
            hostInfoPart = authSegment.substring(p + USER_HOST_SEPARATOR.length());
        }
        return new Pair<>(userInfoPart, hostInfoPart);
    }

    /**
     * Parses the given user information which is formed by the parts [user][:password].
     * 
     * @param userInfo
     *            the string containing the user information
     * @return a {@link Pair} containing the user and password information or null if the user information can't be parsed
     */
    public static Pair<String, String> parseUserInfo(String userInfo) {
        if (isNullOrEmpty(userInfo)) {
            return null;
        }
        String[] userInfoParts = userInfo.split(USER_PASS_SEPARATOR, 2);
        String userName = userInfoParts[0];
        String password = userInfoParts.length > 1 ? userInfoParts[1] : null;
        return new Pair<>(userName, password);
    }

    /**
     * Parses a host:port pair and returns the two elements in a {@link Pair}
     * 
     * @param hostInfo
     *            the host:pair to parse
     * @return a {@link Pair} containing the host and port information or null if the host information can't be parsed
     */
    public static Pair<String, Integer> parseHostPortPair(String hostInfo) {
        if (isNullOrEmpty(hostInfo)) {
            return null;
        }
        Matcher matcher = GENERIC_HOST_PTRN.matcher(hostInfo);
        if (matcher.matches()) {
            String host = matcher.group("host");
            String portAsString = decode(safeTrim(matcher.group("port")));
            Integer portAsInteger = -1;
            if (!isNullOrEmpty(portAsString)) {
                try {
                    portAsInteger = Integer.parseInt(portAsString);
                } catch (NumberFormatException e) {
                    throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.3", new Object[] { hostInfo }),
                            e);
                }
            }
            return new Pair<>(host, portAsInteger);
        }
        throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.3", new Object[] { hostInfo }));
    }

    /**
     * Parses the connection properties section and stores the extracted key/value pairs into a local map.
     */
    private void parseQuerySection() {
        if (isNullOrEmpty(this.query)) {
            this.parsedProperties = new HashMap<>();
            return;
        }
        this.parsedProperties = processKeyValuePattern(PROPERTIES_PTRN, this.query);
    }

    /**
     * Takes a two-matching-groups (respectively named "key" and "value") pattern which is successively tested against the given string and produces a key/value
     * map with the matched values. The given pattern must ensure that there are no leftovers between successive tests, i.e., the end of the previous match must
     * coincide with the beginning of the next.
     * 
     * @param pattern
     *            the regular expression pattern to match against to
     * @param input
     *            the input string
     * @return a key/value map containing the matched values
     */
    private Map<String, String> processKeyValuePattern(Pattern pattern, String input) {
        Matcher matcher = pattern.matcher(input);
        int p = 0;
        Map<String, String> kvMap = new HashMap<>();
        while (matcher.find()) {
            if (matcher.start() != p) {
                throw ExceptionFactory.createException(WrongArgumentException.class,
                        Messages.getString("ConnectionString.4", new Object[] { input.substring(p) }));
            }
            String key = decode(safeTrim(matcher.group("key")));
            String value = decode(safeTrim(matcher.group("value")));
            if (!isNullOrEmpty(key)) {
                kvMap.put(key, value);
            } else if (!isNullOrEmpty(value)) {
                throw ExceptionFactory.createException(WrongArgumentException.class,
                        Messages.getString("ConnectionString.4", new Object[] { input.substring(p) }));
            }
            p = matcher.end();
        }
        if (p != input.length()) {
            throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("ConnectionString.4", new Object[] { input.substring(p) }));
        }
        return kvMap;
    }

    /**
     * URL-decode the given string.
     * 
     * @param text
     *            the string to decode
     * @return
     *         the decoded string
     */
    private static String decode(String text) {
        if (isNullOrEmpty(text)) {
            return text;
        }
        try {
            return URLDecoder.decode(text, StandardCharsets.UTF_8.name());
        } catch (UnsupportedEncodingException e) {
            // Won't happen.
        }
        return "";
    }

    /**
     * Returns the original database URL that produced this connection string parser.
     * 
     * @return the original database URL
     */
    @Override
    public String getDatabaseUrl() {
        return this.baseConnectionString;
    }

    /**
     * Returns the scheme section.
     * 
     * @return the scheme section
     */
    public String getScheme() {
        return this.scheme;
    }

    /**
     * Returns the authority section.
     * 
     * @return the authority section
     */
    public String getAuthority() {
        return this.authority;
    }

    /**
     * Returns the path section.
     * 
     * @return the path section
     */
    public String getPath() {
        return this.path;
    }

    /**
     * Returns the query section.
     * 
     * @return the query section
     */
    public String getQuery() {
        return this.query;
    }

    /**
     * Returns the hosts information.
     * 
     * @return the hosts information
     */
    public List<HostInfo> getHosts() {
        if (this.parsedHosts == null) {
            this.parsedHosts = new ArrayList<>();
            parseAuthoritySection();
        }
        return this.parsedHosts;
    }

    /**
     * Returns the properties map contained in this connection string.
     * 
     * @return the properties map
     */
    public Map<String, String> getProperties() {
        if (this.parsedProperties == null) {
            parseQuerySection();
        }
        return Collections.unmodifiableMap(this.parsedProperties);
    }

    /**
     * Returns a string representation of this object.
     * 
     * @return a string representation of this object
     */
    @Override
    public String toString() {
        StringBuilder asStr = new StringBuilder(super.toString());
        asStr.append(String.format(" :: {scheme: \"%s\", authority: \"%s\", path: \"%s\", query: \"%s\", parsedHosts: %s, parsedProperties: %s}", this.scheme,
                this.authority, this.path, this.query, this.parsedHosts, this.parsedProperties));
        return asStr.toString();
    }

    /**
     * This class is a simple container for two elements.
     * 
     * @param <T>
     *            left part type
     * @param <U>
     *            right part type
     */
    public static class Pair<T, U> {
        public final T left;
        public final U right;

        public Pair(T left, U right) {
            this.left = left;
            this.right = right;
        }

        @Override
        public String toString() {
            StringBuilder asStr = new StringBuilder(super.toString());
            asStr.append(String.format(" :: { left: %s, right: %s }", this.left, this.right));
            return asStr.toString();
        }
    }
}