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

import static java.lang.String.format ;
import static org.apache.jena.fuseki.server.CounterName.QueryTimeouts ;
import static org.apache.jena.riot.WebContent.ctHTMLForm ;
import static org.apache.jena.riot.WebContent.ctSPARQLQuery ;
import static org.apache.jena.riot.WebContent.isHtmlForm ;
import static org.apache.jena.riot.WebContent.matchContentType ;
import static org.apache.jena.riot.web.HttpNames.paramAccept ;
import static org.apache.jena.riot.web.HttpNames.paramCallback ;
import static org.apache.jena.riot.web.HttpNames.paramDefaultGraphURI ;
import static org.apache.jena.riot.web.HttpNames.paramForceAccept ;
import static org.apache.jena.riot.web.HttpNames.paramNamedGraphURI ;
import static org.apache.jena.riot.web.HttpNames.paramOutput1 ;
import static org.apache.jena.riot.web.HttpNames.paramOutput2 ;
import static org.apache.jena.riot.web.HttpNames.paramQuery ;
import static org.apache.jena.riot.web.HttpNames.paramQueryRef ;
import static org.apache.jena.riot.web.HttpNames.paramStyleSheet ;
import static org.apache.jena.riot.web.HttpNames.paramTimeout ;

import java.io.IOException ;
import java.io.InputStream ;
import java.util.* ;

import javax.servlet.http.HttpServletRequest ;
import javax.servlet.http.HttpServletResponse ;

import org.apache.jena.atlas.io.IO ;
import org.apache.jena.atlas.io.IndentedLineBuffer ;
import org.apache.jena.atlas.web.ContentType ;
import org.apache.jena.fuseki.Fuseki ;
import org.apache.jena.fuseki.FusekiException ;
import org.apache.jena.fuseki.FusekiLib ;
import org.apache.jena.query.* ;
import org.apache.jena.rdf.model.Model ;
import org.apache.jena.riot.web.HttpNames ;
import org.apache.jena.riot.web.HttpOp ;
import org.apache.jena.sparql.core.Prologue ;
import org.apache.jena.sparql.resultset.SPARQLResult ;
import org.apache.jena.web.HttpSC ;

/** Handle SPARQL Query requests over the SPARQL Protocol.
 * Subclasses provide this algorithm with the actual dataset to query, whether
 * a dataset hosted by this server ({@link SPARQL_QueryDataset}) or
 * specified in the protocol request ({@link SPARQL_QueryGeneral}).
 */
public abstract class SPARQL_Query extends SPARQL_Protocol
{
    private static final String QueryParseBase = Fuseki.BaseParserSPARQL ;

    public SPARQL_Query() {
        super() ;
    }

    // Choose REST verbs to support.
    
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) {
        doCommon(request, response) ;
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) {
        doCommon(request, response) ;
    }

    @Override
    protected void doOptions(HttpServletRequest request, HttpServletResponse response) {
        setCommonHeadersForOptions(response) ;
        response.setHeader(HttpNames.hAllow, "GET,OPTIONS,POST") ;
        response.setHeader(HttpNames.hContentLengh, "0") ;
    }

    protected void doOptions(HttpAction action) {
        doOptions(action.request, action.response) ;
    }

    // All the params we support

    protected static List<String> allParams = Arrays.asList(paramQuery, paramDefaultGraphURI, paramNamedGraphURI,
                                                            paramQueryRef, paramStyleSheet, paramAccept, paramOutput1,
                                                            paramOutput2, paramCallback, paramForceAccept, paramTimeout) ;

    /**
     * Validate the request, checking HTTP method and HTTP Parameters.
     * @param action HTTP Action
     */
    @Override
    protected void validate(HttpAction action) {
        String method = action.request.getMethod().toUpperCase(Locale.ROOT) ;

        if ( HttpNames.METHOD_OPTIONS.equals(method) )
            return ;

        if ( !HttpNames.METHOD_POST.equals(method) && !HttpNames.METHOD_GET.equals(method) )
            ServletOps.errorMethodNotAllowed("Not a GET or POST request") ;

        if ( HttpNames.METHOD_GET.equals(method) && action.request.getQueryString() == null ) {
            ServletOps.warning(action, "Service Description / SPARQL Query / " + action.request.getRequestURI()) ;
            ServletOps.errorNotFound("Service Description: " + action.request.getRequestURI()) ;
        }

        // Use of the dataset describing parameters is check later.
        try {
            validateParams(action, allParams) ;
            validateRequest(action) ;
        } catch (ActionErrorException ex) {
            throw ex ;
        }
        // Query not yet parsed.
    }

    /**
     * Validate the request after checking HTTP method and HTTP Parameters.
     * @param action HTTP Action
     */
    protected abstract void validateRequest(HttpAction action) ;

    /**
     * Helper method for validating request.
     * @param request HTTP request
     * @param params parameters in a collection of Strings
     */
    protected void validateParams(HttpAction action, Collection<String> params) {
        HttpServletRequest request = action.request ;
        ContentType ct = FusekiLib.getContentType(request) ;
        boolean mustHaveQueryParam = true ;
        if ( ct != null ) {
            String incoming = ct.getContentType() ;

            if ( matchContentType(ctSPARQLQuery, ct) ) {
                mustHaveQueryParam = false ;
                // Drop through.
            } else if ( matchContentType(ctHTMLForm, ct)) {
                // Nothing specific to do
            }
            else
                ServletOps.error(HttpSC.UNSUPPORTED_MEDIA_TYPE_415, "Unsupported: " + incoming) ;
        }

        // GET/POST of a form at this point.

        if ( mustHaveQueryParam ) {
            int N = countParamOccurences(request, paramQuery) ;

            if ( N == 0 )
                ServletOps.errorBadRequest("SPARQL Query: No 'query=' parameter") ;
            if ( N > 1 )
                ServletOps.errorBadRequest("SPARQL Query: Multiple 'query=' parameters") ;

            // application/sparql-query does not use a query param.
            String queryStr = request.getParameter(HttpNames.paramQuery) ;

            if ( queryStr == null )
                ServletOps.errorBadRequest("SPARQL Query: No query specified (no 'query=' found)") ;
            if ( queryStr.isEmpty() )
                ServletOps.errorBadRequest("SPARQL Query: Empty query string") ;
        }

        if ( params != null ) {
            Enumeration<String> en = request.getParameterNames() ;
            for (; en.hasMoreElements();) {
                String name = en.nextElement() ;
                if ( !params.contains(name) )
                    ServletOps.warning(action, "SPARQL Query: Unrecognize request parameter (ignored): " + name) ;
            }
        }
    }

    @Override
    protected final void perform(HttpAction action) {
        // OPTIONS
        if ( action.request.getMethod().equals(HttpNames.METHOD_OPTIONS) ) {
            // Share with update via SPARQL_Protocol.
            doOptions(action) ;
            return ;
        }

        // GET
        if ( action.request.getMethod().equals(HttpNames.METHOD_GET) ) {
            executeWithParameter(action) ;
            return ;
        }

        ContentType ct = FusekiLib.getContentType(action) ;

        // POST application/x-www-form-url
        // POST ?query= and no Content-Type
        if ( ct == null || isHtmlForm(ct) ) {
            // validation checked that if no Content-type, then its a POST with ?query=
            executeWithParameter(action) ;
            return ;
        }

        // POST application/sparql-query
        if ( matchContentType(ct, ctSPARQLQuery) ) {
            executeBody(action) ;
            return ;
        }

        ServletOps.error(HttpSC.UNSUPPORTED_MEDIA_TYPE_415, "Bad content type: " + ct.getContentType()) ;
    }

    protected void executeWithParameter(HttpAction action) {
        String queryString = action.request.getParameter(paramQuery) ;
        execute(queryString, action) ;
    }

    protected void executeBody(HttpAction action) {
        String queryString = null ;
        try {
            InputStream input = action.request.getInputStream() ;
            queryString = IO.readWholeFileAsUTF8(input) ;
        } catch (IOException ex) {
            ServletOps.errorOccurred(ex) ;
        }
        execute(queryString, action) ;
    }

    protected void execute(String queryString, HttpAction action) {
        String queryStringLog = ServletOps.formatForLog(queryString) ;
        if ( action.verbose ) {
            String str = queryString;
            if ( str.endsWith("\n") )
                str = str.substring(0, str.length()-1);
            action.log.info(format("[%d] Query = \n%s", action.id, str)) ;
        }
        else
            action.log.info(format("[%d] Query = %s", action.id, queryStringLog)) ;

        Query query = null ;
        try {
            // NB syntax is ARQ (a superset of SPARQL)
            query = QueryFactory.create(queryString, QueryParseBase, Syntax.syntaxARQ) ;
            queryStringLog = formatForLog(query) ;
            validateQuery(action, query) ;
        } catch (ActionErrorException ex) {
            throw ex ;
        } catch (QueryParseException ex) {
            ServletOps.errorBadRequest("Parse error: \n" + queryString + "\n\r" + messageForQueryException(ex)) ;
        }
        // Should not happen.
        catch (QueryException ex) {
            ServletOps.errorBadRequest("Error: \n" + queryString + "\n\r" + ex.getMessage()) ;
        }

        // Assumes finished whole thing by end of sendResult.
        try {
            action.beginRead() ;
            Dataset dataset = decideDataset(action, query, queryStringLog) ;
            try ( QueryExecution qExec = createQueryExecution(query, dataset) ; ) {
                SPARQLResult result = executeQuery(action, qExec, query, queryStringLog) ;
                // Deals with exceptions itself.
                sendResults(action, result, query.getPrologue()) ;
            }
        }
        catch (QueryParseException ex) {
            // Late stage static error (e.g. bad fixed Lucene query string).
            ServletOps.errorBadRequest("Query parse error: \n" + queryString + "\n\r" + messageForQueryException(ex)) ;
        }
        catch (QueryCancelledException ex) {
            // Additional counter information.
            incCounter(action.getEndpoint().getCounters(), QueryTimeouts) ;
            throw ex ;
        } finally { action.endRead() ; }
    }

    /**
     * Check the query - if unacceptable, throw ActionErrorException or call
     * super.error
     * @param action HTTP Action
     * @param query  SPARQL Query
     */
    protected abstract void validateQuery(HttpAction action, Query query) ;

    /** Create the {@link QueryExecution} for this operation.
     * @param query
     * @param dataset
     * @return QueryExecution
     */
    protected QueryExecution createQueryExecution(Query query, Dataset dataset) {
        return QueryExecutionFactory.create(query, dataset) ;
    }

    /** Perform the {@link QueryExecution} once.
     * @param action
     * @param queryExecution
     * @param query
     * @param queryStringLog Informational string created from the initial query.
     * @return
     */
    protected SPARQLResult executeQuery(HttpAction action, QueryExecution queryExecution, Query query, String queryStringLog) {
        setAnyProtocolTimeouts(queryExecution, action) ;

        if ( query.isSelectType() ) {
            ResultSet rs = queryExecution.execSelect() ;

            // Force some query execution now.
            // If the timeout-first-row goes off, the output stream has not
            // been started so the HTTP error code is sent.

            rs.hasNext() ;

            // If we wanted perfect query time cancellation, we could consume
            // the result now to see if the timeout-end-of-query goes off.
            // rs = ResultSetFactory.copyResults(rs) ;

            //action.log.info(format("[%d] exec/select", action.id)) ;
            return new SPARQLResult(rs) ;
        }

        if ( query.isConstructType() ) {
            Dataset dataset = queryExecution.execConstructDataset();
            //action.log.info(format("[%d] exec/construct", action.id));
            return new SPARQLResult(dataset);
        }

        if ( query.isDescribeType() ) {
            Model model = queryExecution.execDescribe() ;
            //action.log.info(format("[%d] exec/describe", action.id)) ;
            return new SPARQLResult(model) ;
        }

        if ( query.isAskType() ) {
            boolean b = queryExecution.execAsk() ;
            //action.log.info(format("[%d] exec/ask", action.id)) ;
            return new SPARQLResult(b) ;
        }

        ServletOps.errorBadRequest("Unknown query type - " + queryStringLog) ;
        return null ;
    }

    private void setAnyProtocolTimeouts(QueryExecution qexec, HttpAction action) {
//        if ( !(action.getDataService().allowTimeoutOverride) )
//            return ;

        long desiredTimeout = Long.MAX_VALUE ;
        String timeoutHeader = action.request.getHeader("Timeout") ;
        String timeoutParameter = action.request.getParameter("timeout") ;
        if ( timeoutHeader != null ) {
            try {
                desiredTimeout = (int)(Float.parseFloat(timeoutHeader) * 1000) ;
            } catch (NumberFormatException e) {
                throw new FusekiException("Timeout header must be a number", e) ;
            }
        } else if ( timeoutParameter != null ) {
            try {
                desiredTimeout = (int)(Float.parseFloat(timeoutParameter) * 1000) ;
            } catch (NumberFormatException e) {
                throw new FusekiException("timeout parameter must be a number", e) ;
            }
        }

//        desiredTimeout = Math.min(action.getDataService().maximumTimeoutOverride, desiredTimeout) ;
        if ( desiredTimeout != Long.MAX_VALUE )
            qexec.setTimeout(desiredTimeout) ;
    }

    /** Choose the dataset for this SPARQL Query request.
     * @param action
     * @param query  Query - this may be modified to remove a DatasetDescription.
     * @param queryStringLog
     * @return {@link Dataset}
     */
    protected abstract Dataset decideDataset(HttpAction action, Query query, String queryStringLog) ;

    /** Ship the results to the remote caller.
     * @param action
     * @param result
     * @param qPrologue
     */
    protected void sendResults(HttpAction action, SPARQLResult result, Prologue qPrologue) {
        if ( result.isResultSet() )
            ResponseResultSet.doResponseResultSet(action, result.getResultSet(), qPrologue) ;
        else if ( result.isDataset() )
            // CONSTRUCT is processed as a extended CONSTRUCT - result is a dataset.
            ResponseDataset.doResponseDataset(action, result.getDataset());
        else if ( result.isModel() )
            // DESCRIBE results are models
            ResponseDataset.doResponseModel(action, result.getModel());
        else if ( result.isBoolean() )
            ResponseResultSet.doResponseResultSet(action, result.getBooleanResult()) ;
        else
            ServletOps.errorOccurred("Unknown or invalid result type") ;
    }

    private String formatForLog(Query query) {
        IndentedLineBuffer out = new IndentedLineBuffer() ;
        out.setFlatMode(true) ;
        query.serialize(out) ;
        return out.asString() ;
    }

    private String getRemoteString(String queryURI) {
        return HttpOp.execHttpGetString(queryURI) ;
    }

}
