/*
 * Copyright 2019 LinkedIn Corp. Licensed under the BSD 2-Clause License (the "License"). See License in the project root for license information.
 */

package com.linkedin.kafka.cruisecontrol.servlet.purgatory;

import com.linkedin.kafka.cruisecontrol.common.KafkaCruiseControlThreadFactory;
import com.linkedin.kafka.cruisecontrol.config.KafkaCruiseControlConfig;
import com.linkedin.kafka.cruisecontrol.config.constants.WebServerConfig;
import com.linkedin.kafka.cruisecontrol.servlet.CruiseControlEndPoint;
import com.linkedin.kafka.cruisecontrol.servlet.UserRequestException;
import com.linkedin.cruisecontrol.servlet.parameters.CruiseControlParameters;
import com.linkedin.kafka.cruisecontrol.servlet.UserTaskManager;
import com.linkedin.kafka.cruisecontrol.servlet.parameters.ParameterUtils;
import com.linkedin.kafka.cruisecontrol.servlet.response.ReviewResult;
import java.io.Closeable;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static com.linkedin.kafka.cruisecontrol.servlet.CruiseControlEndPoint.REVIEW;
import static com.linkedin.kafka.cruisecontrol.servlet.KafkaCruiseControlServletUtils.httpServletRequestToString;
import static com.linkedin.kafka.cruisecontrol.servlet.KafkaCruiseControlServletUtils.POST_METHOD;
import static com.linkedin.kafka.cruisecontrol.servlet.parameters.ParameterUtils.hasValidParameterNames;


/**
 * A Class to keep POST requests that are awaiting review if two-step verification is enabled.
 *
 * The Purgatory is thread-safe, and is relevant only when
 * {@link WebServerConfig#TWO_STEP_VERIFICATION_ENABLED_CONFIG} is enabled.
 */
public class Purgatory implements Closeable {
  private static final Logger LOG = LoggerFactory.getLogger(Purgatory.class);
  private static final long PURGATORY_CLEANER_PERIOD_SECONDS = 10;
  private static final long PURGATORY_CLEANER_INITIAL_DELAY_SECONDS = 0;
  private int _requestId;
  private final long _purgatoryRetentionTimeMs;
  private final Map<Integer, RequestInfo> _requestInfoById;
  private final ScheduledExecutorService _purgatoryCleaner =
      Executors.newSingleThreadScheduledExecutor(new KafkaCruiseControlThreadFactory("PurgatoryCleaner", true, null));
  private final KafkaCruiseControlConfig _config;

  public Purgatory(KafkaCruiseControlConfig config) {
    _requestId = 0;
    _config = config;
    _purgatoryRetentionTimeMs = config.getLong(WebServerConfig.TWO_STEP_PURGATORY_RETENTION_TIME_MS_CONFIG);
    int purgatoryMaxCachedRequests = config.getInt(WebServerConfig.TWO_STEP_PURGATORY_MAX_REQUESTS_CONFIG);

    _requestInfoById = new LinkedHashMap<Integer, RequestInfo>() {
      @Override
      protected boolean removeEldestEntry(Map.Entry<Integer, RequestInfo> eldest) {
        return this.size() > purgatoryMaxCachedRequests;
      }
    };

    _purgatoryCleaner.scheduleAtFixedRate(new PurgatoryCleaner(),
                                          PURGATORY_CLEANER_INITIAL_DELAY_SECONDS,
                                          PURGATORY_CLEANER_PERIOD_SECONDS,
                                          TimeUnit.SECONDS);
  }

  /**
   * Add request to the purgatory and return the {@link ReviewResult} for the request that has been added to
   * the purgatory.
   *
   * @param request Http Servlet Request to add to the purgatory.
   * @param parameters Request parameters.
   * @param <P> Type corresponding to the request parameters.
   * @return The result showing the {@link ReviewResult} for the request that has been added to the purgatory.
   */
  private synchronized <P extends CruiseControlParameters> ReviewResult addRequest(HttpServletRequest request,
                                                                                   P parameters) {
    if (!request.getMethod().equals(POST_METHOD)) {
      throw new IllegalArgumentException(String.format("Purgatory can only contain POST request (Attempted to add: %s).",
                                                       httpServletRequestToString(request)));
    }
    RequestInfo requestInfo = new RequestInfo(request, parameters);
    _requestInfoById.put(_requestId, requestInfo);

    Map<Integer, RequestInfo> requestInfoById = new HashMap<>();
    requestInfoById.put(_requestId, requestInfo);
    Set<Integer> filteredRequestIds = new HashSet<>();
    filteredRequestIds.add(_requestId);

    ReviewResult result = new ReviewResult(requestInfoById, filteredRequestIds, _config);
    _requestId++;
    return result;
  }

  /**
   * Add the given request to the purgatory unless:
   * <ul>
   *   <li>Request is already in the purgatory and contains the corresponding reviewId to retrieve its parameters.</li>
   *   <li>Request contains invalid parameter names.</li>
   *   <li>Parameters specified in the request cannot be parsed.</li>
   * </ul>
   *
   * @param request HTTP request received by Cruise Control.
   * @param response HTTP response of Cruise Control. Populated in case the request is not already in the purgatory.
   * @param classConfig Config indicating the class of the pluggable parameter.
   * @param parameterConfigOverrides Configs to override upon creating the pluggable parameter.
   * @param userTaskManager a reference to {@link UserTaskManager}
   * @return Parameters of the request if it is in the purgatory, and requested with the corresponding reviewId,
   * {@code null} otherwise.
   */
  public CruiseControlParameters maybeAddToPurgatory(HttpServletRequest request,
                                                     HttpServletResponse response,
                                                     String classConfig,
                                                     Map<String, Object> parameterConfigOverrides,
                                                     UserTaskManager userTaskManager) throws IOException {
    Integer reviewId = ParameterUtils.reviewId(request, true);
    if (reviewId != null) {
      // Submit the request with reviewId that should already be in the purgatory associated with the request endpoint.
      RequestInfo requestInfo = submit(reviewId, request);
      // Ensure that if the request has already been submitted, the user is not attempting to create another user task
      // with the same parameters and endpoint.
      sanityCheckSubmittedRequest(request, requestInfo, userTaskManager);

      return requestInfo.parameters();
    } else {
      CruiseControlParameters parameters = _config.getConfiguredInstance(classConfig,
                                                                         CruiseControlParameters.class,
                                                                         parameterConfigOverrides);
      if (hasValidParameterNames(request, response, _config, parameters) && !parameters.parseParameters(response)) {
        // Add request to purgatory and return ReviewResult.
        ReviewResult reviewResult = addRequest(request, parameters);
        reviewResult.writeSuccessResponse(parameters, response);
        LOG.info("Added request {} (parameters: {}) to purgatory.", request.getPathInfo(), request.getParameterMap());
      }

      return null;
    }
  }

  private static void sanityCheckSubmittedRequest(HttpServletRequest request, RequestInfo requestInfo, UserTaskManager userTaskManager) {
    if (requestInfo.accessToAlreadySubmittedRequest()
        && userTaskManager.getUserTaskByUserTaskId(userTaskManager.getUserTaskId(request), request) == null) {
      throw new UserRequestException(
          String.format("Attempt to start a new user task with an already submitted review. If you are trying to retrieve"
                        + " the result of a submitted execution, please use its UUID in your request header via %s flag."
                        + " If you are starting a new execution with the same parameters, please submit a new review "
                        + "request and get approval for it.", UserTaskManager.USER_TASK_HEADER_NAME));
    }
  }

  /**
   * Ensure that:
   * <ul>
   *   <li>A request with the given review id exists in the purgatory.</li>
   *   <li>The request with the given review id matches the given request.</li>
   *   <li>The request with the given review id is approved in the purgatory.</li>
   * </ul>
   *
   * Then mark the review status as submitted.
   *
   * @param reviewId The review id for which the corresponding request is requested to be submitted.
   * @param request The request to submit.
   * @return Submitted request info.
   */
  public synchronized RequestInfo submit(int reviewId, HttpServletRequest request) {
    RequestInfo requestInfo = _requestInfoById.get(reviewId);
    // 1. Ensure that a request with the given review id exists in the purgatory.
    if (requestInfo == null) {
      throw new UserRequestException(
          String.format("No request with review id %d exists in purgatory. Please use %s endpoint to check for the "
                        + "current requests awaiting review in purgatory.", reviewId, REVIEW));
    }

    // 2. Ensure that the request with the given review id matches the given request.
    CruiseControlEndPoint endpoint = ParameterUtils.endPoint(request);
    if (requestInfo.endPoint() != endpoint) {
      throw new UserRequestException(
          String.format("Request with review id %d is associated with %s endpoint, but the given request has %s endpoint."
                        + "Please use %s endpoint to check for the current requests awaiting review in purgatory.",
                        reviewId, requestInfo.endPoint(), endpoint, REVIEW));
    }

    if (requestInfo.status() == ReviewStatus.SUBMITTED) {
      LOG.info("Request {} has already been submitted (review: {}).", requestInfo.endpointWithParams(), reviewId);
      requestInfo.setAccessToAlreadySubmittedRequest();
    } else {
      // 3. Ensure that the request with the given review id is approved in the purgatory, and mark the status as submitted.
      requestInfo.submitReview(reviewId);
      LOG.info("Submitted request {} for execution (review: {}).", requestInfo.endpointWithParams(), reviewId);
    }
    return requestInfo;
  }

  /**
   * Remove the {@link ReviewStatus#SUBMITTED} request associated with the given review id from purgatory.
   *
   * @param reviewId Review id of the request to be removed from the purgatory.
   * @return Removed submitted request if exists in purgatory, null otherwise.
   */
  public synchronized RequestInfo removeSubmitted(int reviewId) {
    RequestInfo requestInfo = _requestInfoById.get(reviewId);
    if (requestInfo == null) {
      return null;
    } else if (requestInfo.status() != ReviewStatus.SUBMITTED) {
      throw new IllegalStateException(
          String.format("Attempt to remove request associated with review id %d from purgatory. Status (current %s, "
                        + "expected: %s).", reviewId, requestInfo.status(), ReviewStatus.SUBMITTED));
    }

    return _requestInfoById.remove(reviewId);
  }

  /**
   * Get requested reviews from the review board.
   *
   * @param reviewIds Requests for which the result is requested, empty set implies all requests in the review board.
   * @return The requested reviews from the review board.
   */
  public synchronized ReviewResult reviewBoard(Set<Integer> reviewIds) {
    return new ReviewResult(new HashMap<>(_requestInfoById), reviewIds, _config);
  }

  /**
   * Apply the given target states to review the corresponding requests. Get the post-review result of the purgatory.
   *
   * @param requestIdsByTargetState Request Ids by target review state for requests in purgatory.
   * @param reason Common reason for applying the review to the requests.
   * @return The result showing the current purgatory state after the review.
   */
  public synchronized ReviewResult applyReview(Map<ReviewStatus, Set<Integer>> requestIdsByTargetState, String reason) {
    // Sanity check if all request ids in the review exists in the purgatory.
    Set<Integer> reviewedRequestIds = new HashSet<>();
    for (Map.Entry<ReviewStatus, Set<Integer>> entry : requestIdsByTargetState.entrySet()) {
      Set<Integer> requestIds = entry.getValue();
      if (!_requestInfoById.keySet().containsAll(requestIds)) {
        throw new IllegalStateException(String.format("Review contains request ids (%s) that do not exist in purgatory.",
                                                      requestIds.removeAll(_requestInfoById.keySet())));
      }
      // Apply review to each Request Info
      ReviewStatus targetReviewStatus = entry.getKey();
      requestIds.forEach(requestId -> _requestInfoById.get(requestId).applyReview(targetReviewStatus, reason));
      reviewedRequestIds.addAll(requestIds);
    }

    // Return the post-review result of the purgatory.
    return new ReviewResult(new HashMap<>(_requestInfoById), reviewedRequestIds, _config);
  }

  private synchronized void removeOldRequests() {
    LOG.debug("Remove old requests from purgatory.");
    _requestInfoById.entrySet().removeIf(entry -> (entry.getValue().submissionTimeMs()
                                                   + _purgatoryRetentionTimeMs < System.currentTimeMillis()));
  }

  @Override
  public void close() {
    _purgatoryCleaner.shutdownNow();
    _requestInfoById.clear();
  }

  /**
   * A runnable class to remove expired requests.
   */
  private class PurgatoryCleaner implements Runnable {
    @Override
    public void run() {
      try {
        removeOldRequests();
      } catch (Throwable t) {
        LOG.warn("Received exception when trying to remove old requests from purgatory.", t);
      }
    }
  }
}