/**
 * 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.jena.jdbc.remote;

import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.sql.DriverManager;
import java.sql.DriverPropertyInfo;
import java.sql.SQLException;
import java.util.List;
import java.util.Properties;

import org.apache.jena.atlas.logging.Log;
import org.apache.jena.http.auth.AuthLib;
import org.apache.jena.jdbc.JdbcCompatibility;
import org.apache.jena.jdbc.JenaDriver;
import org.apache.jena.jdbc.connections.JenaConnection;
import org.apache.jena.jdbc.remote.connections.RemoteEndpointConnection;
import org.apache.jena.sys.JenaSystem ;

/**
 * <p>
 * A Jena JDBC driver which creates connections to remote endpoints
 * </p>
 * <h3>
 * Connection URL</h3>
 * <p>
 * This driver expects a URL of the following form:
 * </p>
 *
 * <pre>
 * jdbc:jena:remote:query=http://example.org/query&amp;update=http://example.org/update
 * </pre>
 * <p>
 * The {@code query} parameter is used to refer to a SPARQL query endpoint to
 * use, the {@code update} parameter is used to refer to a SPARQL update
 * endpoint to use. At least one of these parameters must be present in order to
 * make a connection, you may omit the former to get a write only connection or
 * the latter to get a read only connection.
 * </p>
 * <p>
 * Note that since the {@code &} character is used as a separator for connection
 * URL parameters if you need your endpoint URLs to include these you should set
 * the relevant parameters directly on the {@link Properties} object you pass to
 * the {@link #connect(String, Properties)} method instead.
 * </p>
 * <h3>Other Supported Parameters</h3>
 * <p>
 * This driver supports a variety of properties that can be used to configure
 * aspects of its behavior, firstly there are a set of properties used to
 * specify the dataset for queries and updates:
 * </p>
 * <ul>
 * <li>{@code default-graph-uri} - Sets a default graph for queries, may be
 * specified multiple times to specify multiple graphs to form the default graph
 * </li>
 * <li>{@code named-graph-uri} - Sets a named graph for queries, may be
 * specified multiple times to specify multiple named graphs for the dataset</li>
 * <li>{@code using-graph-uri} - Sets a default graph for updates, may be
 * specified multiple times to specify multiple graphs to form the default graph
 * for updates</li>
 * <li>{@code using-named-graph-uri} - Sets a named graph for updates, may be
 * specified multiple times to specify multiple named graphs for the dataset</li>
 * <li>{@code select-results-type} - Sets the format to request {@code SELECT}
 * results in from the remote endpoint</li>
 * <li>{@code model-results-type} - Sets the format to request {@code CONSTRUCT}
 * and {@code DESCRIBE} results in from the remote endpoint</li>
 * </ul>
 * <h3>Authentication Parameters</h3>
 * <p>
 * The driver also supports the standard JDBC {@code user} and {@code password}
 * parameters which are used to set user credentials for authenticating to the
 * remote HTTP server.
 * </p>
 * <p>
 * Alternatively you may use the {@code client} parameter to set a
 * specific client implementation to use, must be passed an instance of
 * {@link HttpClient} so can only be passed via the {@link Properties}
 * object and not via the connection URL. If this parameter is used then all
 * other authentication parameters are ignored.
 * </p>
 */
public class RemoteEndpointDriver extends JenaDriver {
    /**
     * Constant for the remote driver prefix, this is appended to the base
     * {@link JenaDriver#DRIVER_PREFIX} to form the URL prefix for JDBC
     * Connection URLs for this driver
     */
    public static final String REMOTE_DRIVER_PREFIX = "remote:";

    /**
     * Constant for the connection URL parameter that sets the remote SPARQL
     * query endpoint to use
     */
    public static final String PARAM_QUERY_ENDPOINT = "query";

    /**
     * Constant for the connection URL parameter that sets the remote SPARQL
     * update endpoint to use
     */
    public static final String PARAM_UPDATE_ENDPOINT = "update";

    /**
     * Constant for the connection URL parameter that sets a default graph URI
     * for SPARQL queries, may be specified multiple times to use specify
     * multiple default graphs to use
     */
    public static final String PARAM_DEFAULT_GRAPH_URI = "default-graph-uri";

    /**
     * Constant for the connection URL parameter that sets a named graph URI for
     * SPARQL queries, may be specified multiple times to use specify multiple
     * named graphs to use
     */
    public static final String PARAM_NAMED_GRAPH_URI = "named-graph-uri";

    /**
     * Constant for the connection URL parameter that sets a default graph URI
     * for SPARQL updates, may be specified multiple times to use specify
     * multiple default graphs to use
     */
    public static final String PARAM_USING_GRAPH_URI = "using-graph-uri";

    /**
     * Constant for the connection URL parameter that sets a named graph URI for
     * SPARQL updates, may be specified multiple times to use specify multiple
     * named graphs to use
     */
    public static final String PARAM_USING_NAMED_GRAPH_URI = "using-named-graph-uri";

    /**
     * Constant for the connection URL parameter that sets the results type to
     * request for {@code SELECT} queries against the remote endpoint
     */
    public static final String PARAM_SELECT_RESULTS_TYPE = "select-results-type";

    /**
     * Constant for the connection URL parameter that sets the results type to
     * request for {@code CONSTRUCT} and {@code DESCRIBE} queries against the
     * remote endpoint
     */
    public static final String PARAM_MODEL_RESULTS_TYPE = "model-results-type";

    /**
     * Constant for the parameter used to specify a client used.
     * <p>
     * It is <strong>important</strong> to be aware that you must pass in an
     * actual instance of a {@link HttpClient} for this parameter so you
     * cannot use directly in the Connection URL and must pass in via the
     * {@link Properties} object.
     * </p>
     */
    public static final String PARAM_CLIENT = "client";


    /**
     * Static initializer block which ensures the driver gets registered
     */
    static {
        try {
            JenaSystem.init() ;
            register();
        } catch (SQLException e) {
            throw new RuntimeException("Failed to register Jena Remote Endpoint JDBC Driver", e);
        }
    }

    /**
     * Registers the driver with the JDBC {@link DriverManager}
     *
     * @throws SQLException
     *             Thrown if the driver cannot be registered
     */
    public static synchronized void register() throws SQLException {
        DriverManager.registerDriver(new RemoteEndpointDriver());
    }

    /**
     * Creates a new driver
     */
    public RemoteEndpointDriver() {
        super(0, 1, REMOTE_DRIVER_PREFIX);
    }

    /**
     * Extension point for derived drivers which allows them to provide
     * different version information and driver prefix
     *
     * @param majorVersion
     *            Major version
     * @param minorVersion
     *            Minor version
     * @param driverPrefix
     *            Driver Prefix
     */
    protected RemoteEndpointDriver(int majorVersion, int minorVersion, String driverPrefix) {
        super(majorVersion, minorVersion, driverPrefix);
    }

    @Override
    protected JenaConnection connect(Properties props, int compatibilityLevel) throws SQLException {
        String queryEndpoint = props.getProperty(PARAM_QUERY_ENDPOINT);
        String updateEndpoint = props.getProperty(PARAM_UPDATE_ENDPOINT);

        // Validate at least one endpoint present
        if (queryEndpoint == null && updateEndpoint == null)
            throw new SQLException("At least one of the " + PARAM_QUERY_ENDPOINT + " or " + PARAM_UPDATE_ENDPOINT
                    + " connection parameters must be specified to make a remote connection");

        // Gather dataset related parameters
        List<String> defaultGraphs = this.getValues(props, PARAM_DEFAULT_GRAPH_URI);
        List<String> namedGraphs = this.getValues(props, PARAM_NAMED_GRAPH_URI);
        List<String> usingGraphs = this.getValues(props, PARAM_USING_GRAPH_URI);
        List<String> usingNamedGraphs = this.getValues(props, PARAM_USING_NAMED_GRAPH_URI);

        // Authentication settings
        HttpClient client = this.configureClient(props);

        // Result Types
        String selectResultsType = props.getProperty(PARAM_SELECT_RESULTS_TYPE, null);
        String modelResultsType = props.getProperty(PARAM_MODEL_RESULTS_TYPE, null);

        // Create connection
        return openConnection(queryEndpoint, updateEndpoint, defaultGraphs, namedGraphs, usingGraphs, usingNamedGraphs,
                client, JenaConnection.DEFAULT_HOLDABILITY, compatibilityLevel, selectResultsType, modelResultsType);
    }

    protected HttpClient configureClient(Properties props) throws SQLException {
        // Try to get credentials to use
        String user = props.getProperty(PARAM_USERNAME, null);
        if (user != null && user.trim().isEmpty()) user = null;
        String password = props.getProperty(PARAM_PASSWORD, null);
        if (password != null && password.trim().isEmpty()) password = null;

        // If credentials then we use them
        if (user != null && password != null) {
            return HttpClient.newBuilder().authenticator(AuthLib.authenticator(user, password)).build();
        }
        // else use a supplied or default client
        Object client = props.get(PARAM_CLIENT);
        if (client != null) {
            if ( client.getClass().getName().equals("org.apache.http.client.HttpClient") ) {
                Log.warn(this, "Found Apache HttpClient for context symbol "+PARAM_CLIENT+". Jena now uses java.net.http.HttpClient");
                return null;
            }
            if (!(client instanceof HttpClient)) throw new SQLException("The " + PARAM_CLIENT
                    + " parameter is specified but the value is not an object implementing the required HttpClient interface");
            return (HttpClient) client;
        }
        return null;
    }

    /**
     * Determines the common base of the two URIs if there is one. The common
     * base will have irrelevant components (fragment and query string) stripped
     * off of it.
     * <p>
     * If one URI is null and the other is non-null the non-null one is
     * returned.
     * </p>
     *
     * @param x
     *            URI
     * @param y
     *            URI
     * @return Common base if it exists, null otherwise.
     */
    protected String getCommonBase(String x, String y) {
        if (x == null) {
            if (y == null) {
                return null;
            } else {
                return stripIrrelevantComponents(y);
            }
        } else if (y == null) {
            return stripIrrelevantComponents(x);
        } else if (x.equals(y)) {
            return stripIrrelevantComponents(x);
        } else {
            // Is one the base of the other?
            if (x.length() < y.length() && y.startsWith(x)) {
                return stripIrrelevantComponents(x);
            } else if (y.length() < x.length() && x.startsWith(y)) {
                return stripIrrelevantComponents(y);
            }

            // Otherwise we should strip last URI component off one/both URIs
            // and recurse
            if (x.length() < y.length()) {
                // y is longer so strip last component
                y = stripLastComponent(y);
            } else if (x.length() > y.length()) {
                // x is longer so strip last component
                x = stripLastComponent(x);
            } else {
                // Equal length so strip last component from both
                x = stripLastComponent(x);
                y = stripLastComponent(y);
            }

            // Be careful that if either returned null at this point bail out
            // Must do this before recursing as otherwise the recursive call
            // will see one input as null and treat as if the non-null is the
            // common base which is in fact incorrect in this case
            if (x == null || y == null) return null;

            return this.getCommonBase(x, y);
        }
    }

    /**
     * Strips the last component of the given URI if possible
     *
     * @param input
     *            URI
     * @return Reduced URI or null if no further reduction is possible
     */
    private static String stripLastComponent(String input) {
        try {
            URI uri = new URI(input);
            if (uri.getFragment() != null) {
                return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), uri.getPort(), uri.getPath(), uri.getQuery(),
                        null).toString();
            } else if (uri.getQuery() != null) {
                return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), uri.getPort(), uri.getPath(), null, null)
                        .toString();
            } else if (uri.getPath() != null) {
                // Try and strip off last segment of the path
                String currPath = uri.getPath();
                if (currPath.endsWith("/")) {
                    currPath = currPath.substring(0, currPath.length() - 1);
                    if (currPath.length() == 0)
                        currPath = null;
                    return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), uri.getPort(), currPath, null, null)
                            .toString();
                } else if (currPath.contains("/")) {
                    currPath = currPath.substring(0, currPath.lastIndexOf('/') + 1);
                    if (currPath.length() == 0)
                        currPath = null;
                    return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), uri.getPort(), currPath, null, null)
                            .toString();
                } else {
                    // If path is non-null it must always contain a /
                    // otherwise it would be an invalid path
                    // In this case there are no further components to strip
                    return null;
                }
            } else {
                // No further components to strip
                return null;
            }
        } catch (URISyntaxException e) {
            // Error stripping component
            return null;
        }
    }

    /**
     * Get the URI with irrelevant components (Fragment and Querystring)
     * stripped off
     *
     * @param input
     *            URI
     * @return URI with irrelevant components stripped off or null if stripping
     *         is impossible
     */
    private static String stripIrrelevantComponents(String input) {
        try {
            URI orig = new URI(input);
            return new URI(orig.getScheme(), orig.getUserInfo(), orig.getHost(), orig.getPort(), orig.getPath(), null, null)
                    .toString();
        } catch (URISyntaxException e) {
            return null;
        }
    }

    /**
     * Opens the actual connection
     * <p>
     * This extension point allows derived drivers to return an extended version
     * of a {@link RemoteEndpointConnection} if they wish or merely to leverage
     * the method to provide a connection after replacing the
     * {@link #connect(Properties, int)} method with their own implementation
     * that uses different connection parameters
     * </p>
     *
     * @param queryEndpoint
     *            SPARQL Query Endpoint
     * @param updateEndpoint
     *            SPARQL Update Endpoint
     * @param defaultGraphs
     *            Default Graphs for queries
     * @param namedGraphs
     *            Named Graphs for queries
     * @param usingGraphs
     *            Default Graphs for updates
     * @param usingNamedGraphs
     *            Named Graphs for updates
     * @param authenticator
     *            HTTP Authenticator
     * @param holdability
     *            Result Set holdability
     * @param compatibilityLevel
     *            JDBC compatibility level, see {@link JdbcCompatibility}
     * @param selectResultsType
     *            Results Type for {@code SELECT} results
     * @param modelResultsType
     *            Results Type for {@code CONSTRUCT} and {@code DESCRIBE}
     *            results
     * @return Remote endpoint connection
     * @throws SQLException
     */
    protected RemoteEndpointConnection openConnection(String queryEndpoint, String updateEndpoint, List<String> defaultGraphs,
            List<String> namedGraphs, List<String> usingGraphs, List<String> usingNamedGraphs, HttpClient client,
            int holdability, int compatibilityLevel, String selectResultsType, String modelResultsType) throws SQLException {
        return new RemoteEndpointConnection(queryEndpoint, updateEndpoint, defaultGraphs, namedGraphs, usingGraphs,
                usingNamedGraphs, client, holdability, compatibilityLevel, selectResultsType, modelResultsType);
    }

    @Override
    protected boolean allowsMultipleValues(String key) {
        if (PARAM_DEFAULT_GRAPH_URI.equals(key) || PARAM_NAMED_GRAPH_URI.equals(key) || PARAM_USING_GRAPH_URI.equals(key)
                || PARAM_USING_NAMED_GRAPH_URI.equals(key)) {
            return true;
        } else {
            return super.allowsMultipleValues(key);
        }
    }

    @Override
    protected DriverPropertyInfo[] getPropertyInfo(Properties connProps, List<DriverPropertyInfo> baseDriverProps) {
        DriverPropertyInfo[] driverProps = new DriverPropertyInfo[10 + baseDriverProps.size()];
        this.copyBaseProperties(driverProps, baseDriverProps, 10);

        // Query Endpoint parameter
        driverProps[0] = new DriverPropertyInfo(PARAM_QUERY_ENDPOINT, connProps.getProperty(PARAM_QUERY_ENDPOINT));
        driverProps[0].required = !connProps.containsKey(PARAM_UPDATE_ENDPOINT);
        driverProps[0].description = "Sets the SPARQL Query endpoint to use for query operations, if this is specified and "
                + PARAM_UPDATE_ENDPOINT + " is not then a read-only connection will be created";

        // Update Endpoint parameter
        driverProps[1] = new DriverPropertyInfo(PARAM_UPDATE_ENDPOINT, connProps.getProperty(PARAM_UPDATE_ENDPOINT));
        driverProps[1].required = !connProps.containsKey(PARAM_UPDATE_ENDPOINT);
        driverProps[1].description = "Sets the SPARQL Update endpoint to use for update operations, if this is specified and "
                + PARAM_QUERY_ENDPOINT + " is not then a write-only connection will be created";

        // Default Graph parameter
        driverProps[2] = new DriverPropertyInfo(PARAM_DEFAULT_GRAPH_URI, null);
        driverProps[2].required = false;
        driverProps[2].description = "Sets the URI for a default graph for queries, may be specified multiple times to specify multiple graphs which should form the default graph";

        // Named Graph parameter
        driverProps[3] = new DriverPropertyInfo(PARAM_NAMED_GRAPH_URI, null);
        driverProps[3].required = false;
        driverProps[3].description = "Sets the URI for a named graph for queries, may be specified multiple times to specify multiple named graphs which should be accessible";

        // Using Graph parameter
        driverProps[4] = new DriverPropertyInfo(PARAM_USING_GRAPH_URI, null);
        driverProps[4].required = false;
        driverProps[4].description = "Sets the URI for a default graph for updates, may be specified multiple times to specify multiple graphs which should form the default graph";

        // Using Named Graph parameter
        driverProps[5] = new DriverPropertyInfo(PARAM_USING_NAMED_GRAPH_URI, null);
        driverProps[5].required = false;
        driverProps[5].description = "Sets the URI for a named graph for updates, may be specified multiple times to specify multiple named graph which should be accessible";

        // Results Types
        driverProps[6] = new DriverPropertyInfo(PARAM_SELECT_RESULTS_TYPE, connProps.getProperty(PARAM_SELECT_RESULTS_TYPE));
        driverProps[6].required = false;
        driverProps[6].description = "Sets the results type for SELECT queries that will be requested from the remote endpoint";
        driverProps[7] = new DriverPropertyInfo(PARAM_MODEL_RESULTS_TYPE, connProps.getProperty(PARAM_MODEL_RESULTS_TYPE));
        driverProps[7].required = false;
        driverProps[7].description = "Sets the results type for CONSTRUCT and DESCRIBE queries that will be requested from the remote endpoint";

        // User Name parameter
        driverProps[8] = new DriverPropertyInfo(PARAM_USERNAME, connProps.getProperty(PARAM_USERNAME));

        // Password parameter
        driverProps[9] = new DriverPropertyInfo(PARAM_PASSWORD, connProps.getProperty(PARAM_PASSWORD));

        return driverProps;
    }
}
