/*
 * 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.geode.rest.internal.web.controllers;

import java.util.concurrent.ConcurrentHashMap;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.apache.logging.log4j.Logger;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import org.apache.geode.cache.Region;
import org.apache.geode.cache.query.FunctionDomainException;
import org.apache.geode.cache.query.NameResolutionException;
import org.apache.geode.cache.query.Query;
import org.apache.geode.cache.query.QueryExecutionLowMemoryException;
import org.apache.geode.cache.query.QueryExecutionTimeoutException;
import org.apache.geode.cache.query.QueryInvalidException;
import org.apache.geode.cache.query.QueryInvocationTargetException;
import org.apache.geode.cache.query.TypeMismatchException;
import org.apache.geode.cache.query.internal.DefaultQuery;
import org.apache.geode.logging.internal.log4j.api.LogService;
import org.apache.geode.rest.internal.web.exception.GemfireRestException;
import org.apache.geode.rest.internal.web.exception.ResourceNotFoundException;
import org.apache.geode.rest.internal.web.util.JSONUtils;
import org.apache.geode.rest.internal.web.util.ValidationUtils;

/**
 * The QueryingController class serves all HTTP REST requests related to the gemfire querying.
 *
 * Spring Security 6.x Migration:
 * - Changed @PreAuthorize authorize() to authorizeBoolean()
 * - Spring Security 6.x authorize() returns AuthorizationDecision instead of boolean
 * - Use authorizeBoolean() for direct boolean evaluation in @PreAuthorize expressions
 *
 * @see org.springframework.stereotype.Controller
 * @since GemFire 8.0
 */
@Controller("queryController")
@Tag(name = "queries", description = "queries")
@RequestMapping(QueryAccessController.REST_API_VERSION + "/queries")
@SuppressWarnings("unused")
public class QueryAccessController extends AbstractBaseController {

  private static final Logger logger = LogService.getLogger();

  static final String PARAMETERIZED_QUERIES_REGION = "__ParameterizedQueries__";

  private final ConcurrentHashMap<String, DefaultQuery> compiledQueries = new ConcurrentHashMap<>();

  // Constant String value indicating the version of the REST API.
  static final String REST_API_VERSION = "/v1";

  /**
   * Gets the version of the REST API implemented by this @Controller.
   *
   * @return a String indicating the REST API version.
   */
  @Override
  protected String getRestApiVersion() {
    return REST_API_VERSION;
  }

  /**
   * list all parametrized Queries created in a Gemfire data node
   *
   * @return result as a JSON document.
   */
  @RequestMapping(method = RequestMethod.GET, produces = {APPLICATION_JSON_UTF8_VALUE})
  @Operation(summary = "list all parametrized queries",
      description = "List all parametrized queries by id/name")
  @ApiResponses({@ApiResponse(responseCode = "200", description = "OK."),
      @ApiResponse(responseCode = "401", description = "Invalid Username or Password."),
      @ApiResponse(responseCode = "403", description = "Insufficient privileges for operation."),
      @ApiResponse(responseCode = "500", description = "if GemFire throws an error or exception")})
  @ResponseBody
  @ResponseStatus(HttpStatus.OK)
  @PreAuthorize("@securityService.authorizeBoolean('DATA', 'READ')")
  public ResponseEntity<?> list() {
    logger.debug("Listing all parametrized Queries in GemFire...");

    final Region<String, String> parametrizedQueryRegion =
        getQueryStore(PARAMETERIZED_QUERIES_REGION);

    String queryListAsJson = JSONUtils.formulateJsonForListQueriesCall(parametrizedQueryRegion);
    final HttpHeaders headers = new HttpHeaders();
    headers.setLocation(toUri("queries"));
    return new ResponseEntity<>(queryListAsJson, headers, HttpStatus.OK);
  }

  /**
   * Create a named, parametrized Query
   *
   * @param queryId uniquely identify the query
   * @param oqlInUrl OQL query string specified in a request URL
   * @param oqlInBody OQL query string specified in a request body
   * @return result as a JSON document.
   */
  @RequestMapping(method = RequestMethod.POST)
  @Operation(summary = "create a parametrized Query",
      description = "Prepare the specified parametrized query and assign the corresponding ID for lookup")
  @ApiResponses({@ApiResponse(responseCode = "201", description = "Successfully created."),
      @ApiResponse(responseCode = "401", description = "Invalid Username or Password."),
      @ApiResponse(responseCode = "403", description = "Insufficient privileges for operation."),
      @ApiResponse(responseCode = "409", description = "QueryId already assigned to other query."),
      @ApiResponse(responseCode = "500", description = "GemFire throws an error or exception.")})
  @PreAuthorize("@securityService.authorizeBoolean('DATA', 'READ')")
  public ResponseEntity<?> create(@RequestParam("id") final String queryId,
      @RequestParam(value = "q", required = false) String oqlInUrl,
      @RequestBody(required = false) final String oqlInBody) {
    final String oqlStatement = validateQuery(oqlInUrl, oqlInBody);
    logger.debug("Creating a named, parametrized Query ({}) with ID ({})...", oqlStatement,
        queryId);


    // store the compiled OQL statement with 'queryId' as the Key into the hidden,
    // Parametrized Queries Region...
    final String existingOql =
        createNamedQuery(PARAMETERIZED_QUERIES_REGION, queryId, oqlStatement);

    final HttpHeaders headers = new HttpHeaders();
    headers.setLocation(toUri("queries", queryId));

    if (existingOql != null) {
      headers.setContentType(APPLICATION_JSON_UTF8);
      return new ResponseEntity<>(JSONUtils.formulateJsonForExistingQuery(queryId, existingOql),
          headers, HttpStatus.CONFLICT);
    } else {
      return new ResponseEntity<>(headers, HttpStatus.CREATED);
    }
  }

  /**
   * Run an adhoc Query specified in a query string
   *
   * @param oql OQL query string to be executed
   * @return query result as a JSON document
   */
  @RequestMapping(method = RequestMethod.GET, value = "/adhoc",
      produces = {APPLICATION_JSON_UTF8_VALUE})
  @Operation(summary = "run an adhoc query",
      description = "Run an unnamed (unidentified), ad-hoc query passed as a URL parameter")
  @ApiResponses({@ApiResponse(responseCode = "200", description = "OK."),
      @ApiResponse(responseCode = "401", description = "Invalid Username or Password."),
      @ApiResponse(responseCode = "403", description = "Insufficient privileges for operation."),
      @ApiResponse(responseCode = "500", description = "GemFire throws an error or exception")})
  @ResponseBody
  @ResponseStatus(HttpStatus.OK)
  @PreAuthorize("@securityService.authorizeBoolean('DATA', 'READ')")
  public ResponseEntity<String> runAdhocQuery(@RequestParam("q") String oql) {
    logger.debug("Running an adhoc Query ({})...", oql);

    oql = decode(oql);
    final Query query = getQueryService().newQuery(oql);

    // NOTE Query.execute throws many checked Exceptions; let the BaseControllerAdvice Exception
    // handlers catch
    // and handle the Exceptions appropriately (500 Server Error)!
    try {
      Object queryResult = query.execute();
      return processQueryResponse(query, null, queryResult);
    } catch (FunctionDomainException fde) {
      throw new GemfireRestException(
          "A function was applied to a parameter that is improper for that function!", fde);
    } catch (TypeMismatchException tme) {
      throw new GemfireRestException("Bind parameter is not of the expected type!", tme);
    } catch (NameResolutionException nre) {
      throw new GemfireRestException("Name in the query cannot be resolved!", nre);
    } catch (IllegalArgumentException iae) {
      throw new GemfireRestException(
          " The number of bound parameters does not match the number of placeholders!", iae);
    } catch (IllegalStateException ise) {
      throw new GemfireRestException("Query is not permitted on this type of region!", ise);
    } catch (QueryExecutionTimeoutException qete) {
      throw new GemfireRestException(
          "Query execution time is exceeded max query execution time (gemfire.Cache.MAX_QUERY_EXECUTION_TIME) configured! ",
          qete);
    } catch (QueryInvocationTargetException qite) {
      throw new GemfireRestException(
          "Data referenced in from clause is not available for querying!", qite);
    } catch (QueryExecutionLowMemoryException qelme) {
      throw new GemfireRestException(
          "Query execution gets canceled due to low memory conditions and the resource manager critical heap percentage has been set!",
          qelme);
    } catch (Exception ex) {
      throw new GemfireRestException(ex.getMessage(), ex);
    }
  }

  /**
   * Run named parametrized Query with ID
   *
   * @param queryId id of the OQL string
   * @param arguments query bind params required while executing query
   * @return query result as a JSON document
   */
  @RequestMapping(method = RequestMethod.POST, value = "/{query}",
      produces = {APPLICATION_JSON_UTF8_VALUE})
  @Operation(summary = "run parametrized query",
      description = "run the specified named query passing in scalar values for query parameters in the GemFire cluster")
  @ApiResponses({@ApiResponse(responseCode = "200", description = "Query successfully executed."),
      @ApiResponse(responseCode = "401", description = "Invalid Username or Password."),
      @ApiResponse(responseCode = "403", description = "Insufficient privileges for operation."),
      @ApiResponse(responseCode = "400",
          description = "Query bind params specified as JSON document in the request body is invalid"),
      @ApiResponse(responseCode = "500", description = "GemFire throws an error or exception")})
  @ResponseBody
  @ResponseStatus(HttpStatus.OK)
  @PreAuthorize("@securityService.authorizeBoolean('DATA', 'READ')")
  public ResponseEntity<String> runNamedQuery(@PathVariable("query") String queryId,
      @RequestBody String arguments) {
    logger.debug("Running named Query with ID ({})...", queryId);

    queryId = decode(queryId);

    if (arguments != null) {
      // Its a compiled query.

      // Convert arguments into Object[]
      Object[] args = jsonToObjectArray(arguments);

      Query compiledQuery = compiledQueries.get(queryId);
      if (compiledQuery == null) {
        // This is first time the query is seen by this server.
        final String oql = getQueryStore(PARAMETERIZED_QUERIES_REGION).get(queryId);

        ValidationUtils.returnValueThrowOnNull(oql, new ResourceNotFoundException(
            String.format("No Query with ID (%1$s) was found!", queryId)));
        try {
          compiledQuery = getQueryService().newQuery(oql);
        } catch (QueryInvalidException qie) {
          throw new GemfireRestException("Syntax of the OQL queryString is invalid!", qie);
        }
        compiledQueries.putIfAbsent(queryId, (DefaultQuery) compiledQuery);
      }
      // NOTE Query.execute throws many checked Exceptions; let the BaseControllerAdvice Exception
      // handlers catch
      // and handle the Exceptions appropriately (500 Server Error)!
      try {
        Object queryResult = compiledQuery.execute(args);
        return processQueryResponse(compiledQuery, args, queryResult);
      } catch (FunctionDomainException fde) {
        throw new GemfireRestException(
            "A function was applied to a parameter that is improper for that function!", fde);
      } catch (TypeMismatchException tme) {
        throw new GemfireRestException("Bind parameter is not of the expected type!", tme);
      } catch (NameResolutionException nre) {
        throw new GemfireRestException("Name in the query cannot be resolved!", nre);
      } catch (IllegalArgumentException iae) {
        throw new GemfireRestException(
            " The number of bound parameters does not match the number of placeholders!", iae);
      } catch (IllegalStateException ise) {
        throw new GemfireRestException("Query is not permitted on this type of region!", ise);
      } catch (QueryExecutionTimeoutException qete) {
        throw new GemfireRestException(
            "Query execution time is exceeded  max query execution time (gemfire.Cache.MAX_QUERY_EXECUTION_TIME) configured!",
            qete);
      } catch (QueryInvocationTargetException qite) {
        throw new GemfireRestException(
            "Data referenced in from clause is not available for querying!", qite);
      } catch (QueryExecutionLowMemoryException qelme) {
        throw new GemfireRestException(
            "Query gets canceled due to low memory conditions and the resource manager critical heap percentage has been set!",
            qelme);
      } catch (Exception e) {
        throw new GemfireRestException("Error encountered while executing named query!", e);
      }
    } else {
      throw new GemfireRestException(
          " Bind params either not specified or not processed properly by the server!");
    }
  }

  /**
   * Update named, parametrized Query
   *
   * @param queryId uniquely identify the query
   * @param oqlInUrl OQL query string specified in a request URL
   * @param oqlInBody OQL query string specified in a request body
   */
  @RequestMapping(method = RequestMethod.PUT, value = "/{query}")
  @Operation(summary = "update parametrized query",
      description = "Update named, parametrized query by ID")
  @ApiResponses({@ApiResponse(responseCode = "200", description = "Updated successfully."),
      @ApiResponse(responseCode = "401", description = "Invalid Username or Password."),
      @ApiResponse(responseCode = "403", description = "Insufficient privileges for operation."),
      @ApiResponse(responseCode = "404", description = "queryId does not exist."),
      @ApiResponse(responseCode = "500", description = "GemFire throws an error or exception.")})
  @PreAuthorize("@securityService.authorizeBoolean('DATA', 'READ')")
  public ResponseEntity<?> update(@PathVariable("query") final String queryId,
      @RequestParam(value = "q", required = false) String oqlInUrl,
      @RequestBody(required = false) final String oqlInBody) {
    final String oqlStatement = validateQuery(oqlInUrl, oqlInBody);

    logger.debug("Updating a named, parametrized Query ({}) with ID ({})...", oqlStatement,
        queryId);

    // update the OQL statement with 'queryId' as the Key into the hidden, Parametrized Queries
    // Region...
    checkForQueryIdExist(PARAMETERIZED_QUERIES_REGION, queryId);
    updateNamedQuery(PARAMETERIZED_QUERIES_REGION, queryId, oqlStatement);
    compiledQueries.remove(queryId);

    return new ResponseEntity<>(HttpStatus.OK);
  }

  /**
   * Delete named, parametrized Query
   *
   * @param queryId uniquely identify the query to be deleted
   */
  @RequestMapping(method = RequestMethod.DELETE, value = "/{query}")
  @Operation(summary = "delete parametrized query",
      description = "delete named, parametrized query by ID")
  @ApiResponses({@ApiResponse(responseCode = "200", description = "Deleted successfully."),
      @ApiResponse(responseCode = "401", description = "Invalid Username or Password."),
      @ApiResponse(responseCode = "403", description = "Insufficient privileges for operation."),
      @ApiResponse(responseCode = "404", description = "queryId does not exist."),
      @ApiResponse(responseCode = "500", description = "GemFire throws an error or exception")})
  @PreAuthorize("@securityService.authorizeBoolean('DATA', 'WRITE')")
  public ResponseEntity<?> delete(@PathVariable("query") final String queryId) {
    logger.debug("Deleting a named, parametrized Query with ID ({}).", queryId);

    // delete the OQL statement with 'queryId' as the Key into the hidden,
    // ParameterizedQueries Region...
    deleteNamedQuery(PARAMETERIZED_QUERIES_REGION, queryId);
    compiledQueries.remove(queryId);
    return new ResponseEntity<>(HttpStatus.OK);
  }

}
