/*
 * Copyright (c) 2015-present, Parse, LLC.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */
package com.parse;

import com.parse.http.ParseHttpBody;
import com.parse.http.ParseHttpRequest;
import com.parse.http.ParseHttpResponse;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONStringer;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.Map;

import bolts.Task;

/**
 * A helper object to send requests to the server.
 */
/** package */ class ParseRESTCommand extends ParseRequest<JSONObject> {

  /* package */ static final String HEADER_APPLICATION_ID = "X-Parse-Application-Id";
  /* package */ static final String HEADER_CLIENT_KEY = "X-Parse-Client-Key";
  /* package */ static final String HEADER_CLIENT_VERSION = "X-Parse-Client-Version";
  /* package */ static final String HEADER_APP_BUILD_VERSION = "X-Parse-App-Build-Version";
  /* package */ static final String HEADER_APP_DISPLAY_VERSION = "X-Parse-App-Display-Version";
  /* package */ static final String HEADER_OS_VERSION = "X-Parse-OS-Version";

  /* package */ static final String HEADER_INSTALLATION_ID = "X-Parse-Installation-Id";
  /* package */ static final String USER_AGENT = "User-Agent";
  private static final String HEADER_SESSION_TOKEN = "X-Parse-Session-Token";
  private static final String HEADER_MASTER_KEY = "X-Parse-Master-Key";
  private static final String PARAMETER_METHOD_OVERRIDE = "_method";

  private static LocalIdManager getLocalIdManager() {
    return ParseCorePlugins.getInstance().getLocalIdManager();
  }

  /* package */ static abstract class Init<T extends Init<T>> {
    private String sessionToken;
    private String installationId;
    public String masterKey;

    private ParseHttpRequest.Method method = ParseHttpRequest.Method.GET;
    private String httpPath;
    private JSONObject jsonParameters;

    private String operationSetUUID;
    private String localId;

    /* package */ abstract T self();

    public T sessionToken(String sessionToken) {
      this.sessionToken = sessionToken;
      return self();
    }

    public T installationId(String installationId) {
      this.installationId = installationId;
      return self();
    }

    public T masterKey(String masterKey) {
      this.masterKey = masterKey;
      return self();
    }

    public T method(ParseHttpRequest.Method method) {
      this.method = method;
      return self();
    }

    public T httpPath(String httpPath) {
      this.httpPath = httpPath;
      return self();
    }

    public T jsonParameters(JSONObject jsonParameters) {
      this.jsonParameters = jsonParameters;
      return self();
    }

    public T operationSetUUID(String operationSetUUID) {
      this.operationSetUUID = operationSetUUID;
      return self();
    }

    public T localId(String localId) {
      this.localId = localId;
      return self();
    }
  }

  public static class Builder extends Init<Builder> {
    @Override
    /* package */ Builder self() {
      return this;
    }

    public ParseRESTCommand build() {
      return new ParseRESTCommand(this);
    }
  }

  // Headers
  private final String sessionToken;
  private String installationId;
  public String masterKey;

  /* package */ String httpPath;
  /* package */ final JSONObject jsonParameters;

  private String operationSetUUID;
  private String localId;

  public ParseRESTCommand(
      String httpPath,
      ParseHttpRequest.Method httpMethod,
      Map<String, ?> parameters,
      String sessionToken) {
    this(
        httpPath,
        httpMethod,
        parameters != null ? (JSONObject) NoObjectsEncoder.get().encode(parameters) : null,
        sessionToken);
  }

  protected ParseRESTCommand(
      String httpPath,
      ParseHttpRequest.Method httpMethod,
      JSONObject jsonParameters,
      String sessionToken) {
    this(httpPath, httpMethod, jsonParameters, null, sessionToken);
  }

  private ParseRESTCommand(
      String httpPath,
      ParseHttpRequest.Method httpMethod,
      JSONObject jsonParameters,
      String localId, String sessionToken) {
    super(httpMethod, createUrl(httpPath));

    this.httpPath = httpPath;
    this.jsonParameters = jsonParameters;
    this.localId = localId;
    this.sessionToken = sessionToken;
  }

  /* package */ ParseRESTCommand(Init<?> builder) {
    super(builder.method, createUrl(builder.httpPath));
    sessionToken = builder.sessionToken;
    installationId = builder.installationId;
    masterKey = builder.masterKey;

    httpPath = builder.httpPath;
    jsonParameters = builder.jsonParameters;
    operationSetUUID = builder.operationSetUUID;
    localId = builder.localId;
  }

  public static ParseRESTCommand fromJSONObject(JSONObject jsonObject) {
    String httpPath = jsonObject.optString("httpPath");
    ParseHttpRequest.Method httpMethod =
        ParseHttpRequest.Method.fromString(jsonObject.optString("httpMethod"));
    String sessionToken = jsonObject.optString("sessionToken", null);
    String localId = jsonObject.optString("localId", null);
    JSONObject jsonParameters = jsonObject.optJSONObject("parameters");

    return new ParseRESTCommand(httpPath, httpMethod, jsonParameters, localId, sessionToken);
  }

  // TODO(grantland): But we don't disable retries by default...
  /* package */ void enableRetrying() {
    setMaxRetries(DEFAULT_MAX_RETRIES);
  }

  private static String createUrl(String httpPath) {
    // We send all parameters for GET/HEAD/DELETE requests in a post body,
    // so no need to worry about query parameters here.
    return String.format("%s/1/%s", ParseObject.server, httpPath);
  }

  protected void addAdditionalHeaders(ParseHttpRequest.Builder requestBuilder) {
    if (installationId != null) {
      requestBuilder.addHeader(HEADER_INSTALLATION_ID, installationId);
    }
    if (sessionToken != null) {
      requestBuilder.addHeader(HEADER_SESSION_TOKEN, sessionToken);
    }
    if (masterKey != null) {
      requestBuilder.addHeader(HEADER_MASTER_KEY, masterKey);
    }
  }

  @Override
  protected ParseHttpRequest newRequest(
      ParseHttpRequest.Method method,
      String url,
      ProgressCallback uploadProgressCallback) {
    ParseHttpRequest request;
    if (jsonParameters != null &&
        method != ParseHttpRequest.Method.POST &&
        method != ParseHttpRequest.Method.PUT) {
      // The request URI may be too long to include parameters in the URI.
      // To avoid this problem we send the parameters in a POST request json-encoded body
      // and add a http method override parameter in newBody.
      request = super.newRequest(ParseHttpRequest.Method.POST, url, uploadProgressCallback);
    } else {
      request = super.newRequest(method, url, uploadProgressCallback);
    }
    ParseHttpRequest.Builder requestBuilder = new ParseHttpRequest.Builder(request);
    addAdditionalHeaders(requestBuilder);
    return requestBuilder.build();
  }

  @Override
  protected ParseHttpBody newBody(ProgressCallback uploadProgressCallback) {
    if (jsonParameters == null) {
      String message = String.format("Trying to execute a %s command without body parameters.",
          method.toString());
      throw new IllegalArgumentException(message);
    }

    try {
      JSONObject parameters = jsonParameters;
      if (method == ParseHttpRequest.Method.GET ||
          method == ParseHttpRequest.Method.DELETE) {
        // The request URI may be too long to include parameters in the URI.
        // To avoid this problem we send the parameters in a POST request json-encoded body
        // and add a http method override parameter.
        parameters = new JSONObject(jsonParameters.toString());
        parameters.put(PARAMETER_METHOD_OVERRIDE, method.toString());
      }
      return new ParseByteArrayHttpBody(parameters.toString(), "application/json");
    } catch (Exception e) {
      throw new RuntimeException(e.getMessage());
    }
  }

  @Override
  public Task<JSONObject> executeAsync(
      final ParseHttpClient client,
      final ProgressCallback uploadProgressCallback,
      final ProgressCallback downloadProgressCallback,
      final Task<Void> cancellationToken) {
    resolveLocalIds();
    return super.executeAsync(
        client, uploadProgressCallback, downloadProgressCallback, cancellationToken);
  }

  @Override
  protected Task<JSONObject> onResponseAsync(ParseHttpResponse response,
      ProgressCallback downloadProgressCallback) {
    String content;
    InputStream responseStream = null;
    try {
      responseStream = response.getContent();
      content = new String(ParseIOUtils.toByteArray(responseStream));
    } catch (IOException e) {
      return Task.forError(e);
    } finally {
      ParseIOUtils.closeQuietly(responseStream);
    }

    // We need to check for errors differently in /1/ than /2/ since object data in /2/ was
    // encapsulated in "data" and everything was 200, but /2/ everything is in the root JSON,
    // but errors are status 4XX.
    // See https://quip.com/4pbbA9HbOPjQ
    int statusCode = response.getStatusCode();
    if (statusCode >= 200 && statusCode < 600) { // Assume 3XX is handled by http library
      JSONObject json;
      try {
        json = new JSONObject(content);

        if (statusCode >= 400 && statusCode < 500) { // 4XX
          return Task.forError(newPermanentException(json.optInt("code"), json.optString("error")));
        } else if (statusCode >= 500) { // 5XX
          return Task.forError(newTemporaryException(json.optInt("code"), json.optString("error")));
        }

        return Task.forResult(json);
      } catch (JSONException e) {
        return Task.forError(newTemporaryException("bad json response", e));
      }
    }

    return Task.forError(newPermanentException(ParseException.OTHER_CAUSE, content));
  }

  // Creates a somewhat-readable string that uniquely identifies this command.
  public String getCacheKey() {
    String json;
    if (jsonParameters != null) {
      try {
        json = toDeterministicString(jsonParameters);
      } catch (JSONException e) {
        throw new RuntimeException(e.getMessage());
      }
    } else {
      json = "";
    }

    // Include the session token in the cache in order to avoid mixing permissions.
    if (sessionToken != null) {
      json += sessionToken;
    }

    return String.format(
        "ParseRESTCommand.%s.%s.%s",
        method.toString(),
        ParseDigestUtils.md5(httpPath),
        ParseDigestUtils.md5(json)
    );
  }

  // Encodes the object to JSON, but ensures that JSONObjects
  // and nested JSONObjects are encoded with keys in alphabetical order.
  /** package */ static String toDeterministicString(Object o) throws JSONException {
    JSONStringer stringer = new JSONStringer();
    addToStringer(stringer, o);
    return stringer.toString();
  }

  // Uses the provided JSONStringer to encode this object to JSON, but ensures that JSONObjects and
  // nested JSONObjects are encoded with keys in alphabetical order.
  private static void addToStringer(JSONStringer stringer, Object o) throws JSONException {
    if (o instanceof JSONObject) {
      stringer.object();
      JSONObject object = (JSONObject) o;
      Iterator<String> keyIterator = object.keys();
      ArrayList<String> keys = new ArrayList<>();
      while (keyIterator.hasNext()) {
        keys.add(keyIterator.next());
      }
      Collections.sort(keys);

      for (String key : keys) {
        stringer.key(key);
        addToStringer(stringer, object.opt(key));
      }

      stringer.endObject();
      return;
    }

    if (o instanceof JSONArray) {
      JSONArray array = (JSONArray) o;
      stringer.array();
      for (int i = 0; i < array.length(); ++i) {
        addToStringer(stringer, array.get(i));
      }
      stringer.endArray();
      return;
    }

    stringer.value(o);
  }

  /* package */ static boolean isValidCommandJSONObject(JSONObject jsonObject) {
    return jsonObject.has("httpPath");
  }

  // This function checks whether a json object is a valid /2 ParseCommand json.
  /* package */ static boolean isValidOldFormatCommandJSONObject(JSONObject jsonObject) {
    return jsonObject.has("op");
  }

  public JSONObject toJSONObject() {
    JSONObject jsonObject = new JSONObject();
    try {
      if (httpPath != null) {
        jsonObject.put("httpPath", httpPath);
      }
      jsonObject.put("httpMethod", method.toString());
      if (jsonParameters != null) {
        jsonObject.put("parameters", jsonParameters);
      }
      if (sessionToken != null) {
        jsonObject.put("sessionToken", sessionToken);
      }
      if (localId != null) {
        jsonObject.put("localId", localId);
      }
    } catch (JSONException e) {
      throw new RuntimeException(e.getMessage());
    }
    return jsonObject;
  }

  public String getSessionToken() {
    return sessionToken;
  }

  public String getOperationSetUUID() {
    return operationSetUUID;
  }

  /* package */ void setOperationSetUUID(String operationSetUUID) {
    this.operationSetUUID = operationSetUUID;
  }

  public void setLocalId(String localId) {
    this.localId = localId;
  }

  public String getLocalId() {
    return localId;
  }


  /**
   * If this was the second save on a new object while offline, then its objectId wasn't yet set
   * when the command was created, so it would have been considered a "create". But if the first
   * save succeeded, then there is an objectId now, and it will be mapped to the localId for this
   * command's result. If so, change the "create" operation to an "update", and add the objectId to
   * the command.
   */
  private void maybeChangeServerOperation() throws JSONException {
    if (localId != null) {
      String objectId = getLocalIdManager().getObjectId(localId);
      if (objectId != null) {
        localId = null;
        httpPath += String.format("/%s", objectId);
        url = createUrl(httpPath);

        if (httpPath.startsWith("classes") && method == ParseHttpRequest.Method.POST) {
          method = ParseHttpRequest.Method.PUT;
        }
      }
    }
  }

  public void resolveLocalIds() {
    try {
      ArrayList<JSONObject> localPointers = new ArrayList<>();
      getLocalPointersIn(jsonParameters, localPointers);
      for (JSONObject pointer : localPointers) {
        String localId = (String) pointer.get("localId");
        String objectId = getLocalIdManager().getObjectId(localId);
        if (objectId == null) {
          throw new IllegalStateException(
              "Tried to serialize a command referencing a new, unsaved object.");
        }
        pointer.put("objectId", objectId);
        pointer.remove("localId");
      }
      maybeChangeServerOperation();
    } catch (JSONException e) {
      // Well, nothing to do here...
    }
  }

  /**
   * Finds all of the local ids in this command and increments their retain counts in the on-disk
   * store. This should be called immediately before serializing the command to disk, so that we
   * know we might need to resolve these local ids at some point in the future.
   */
  public void retainLocalIds() {
    if (localId != null) {
      getLocalIdManager().retainLocalIdOnDisk(localId);
    }

    try {
      ArrayList<JSONObject> localPointers = new ArrayList<>();
      getLocalPointersIn(jsonParameters, localPointers);
      for (JSONObject pointer : localPointers) {
        String localId = (String) pointer.get("localId");
        getLocalIdManager().retainLocalIdOnDisk(localId);
      }
    } catch (JSONException e) {
      // Well, nothing to do here...
    }
  }

  /**
   * Finds all of the local ids in this command and decrements their retain counts in the on-disk
   * store. This should be called when removing a serialized command from the disk, when we know
   * that we will never need to resolve these local ids for this command again in the future.
   */
  public void releaseLocalIds() {
    if (localId != null) {
      getLocalIdManager().releaseLocalIdOnDisk(localId);
    }
    try {
      ArrayList<JSONObject> localPointers = new ArrayList<>();
      getLocalPointersIn(jsonParameters, localPointers);
      for (JSONObject pointer : localPointers) {
        String localId = (String) pointer.get("localId");
        getLocalIdManager().releaseLocalIdOnDisk(localId);
      }
    } catch (JSONException e) {
      // Well, nothing to do here...
    }
  }

  protected static void getLocalPointersIn(Object container, ArrayList<JSONObject> localPointers)
      throws JSONException {
    if (container instanceof JSONObject) {
      JSONObject object = (JSONObject) container;
      if ("Pointer".equals(object.opt("__type")) && object.has("localId")) {
        localPointers.add((JSONObject) container);
        return;
      }

      Iterator<String> keyIterator = object.keys();
      while (keyIterator.hasNext()) {
        String key = keyIterator.next();
        getLocalPointersIn(object.get(key), localPointers);
      }
    }

    if (container instanceof JSONArray) {
      JSONArray array = (JSONArray) container;
      for (int i = 0; i < array.length(); ++i) {
        getLocalPointersIn(array.get(i), localPointers);
      }
    }
  }
}
