/*
 * Copyright 2015 Chidiebere Okwudire.
 *
 * Licensed 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 com.parse4cn1;

import ca.weblite.codename1.json.JSONArray;
import ca.weblite.codename1.json.JSONException;
import ca.weblite.codename1.json.JSONObject;
import com.codename1.io.Util;
import com.codename1.util.StringUtil;
import com.parse4cn1.command.ParseCommand;
import com.parse4cn1.command.ParsePostCommand;
import com.parse4cn1.command.ParseResponse;
import com.parse4cn1.util.Logger;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * This class enables issuing of batch requests.
 * <p>
 * Batching of requests helps reduce the amount of network round trips
 * performed.
 */
public class ParseBatch {

    private static final Logger LOGGER = Logger.getInstance();
    private final List<ParseObject> parseObjects;
    private final JSONArray data;
    private List<ParseException> results;
    private boolean succeeded = false;

    /**
     * An enumeration of batch operation types.
     */
    public enum EBatchOpType {

        CREATE,
        UPDATE,
        DELETE
    }

    /**
     * Creates a new ParseBatch instance.
     *
     * @return The newly created object.
     */
    public static ParseBatch create() {
        return new ParseBatch();
    }

    /**
     * Adds an object to the batch to be {@link #execute() executed}.
     *
     * @param object The object to be added to the batch.
     * @param opType The type of operation to be performed on {@code object}.
     * @return {@code this} to enable chaining.
     * @throws ParseException if the object does not meet the constraints for
     * {@code opType}, for example, an objectId is required for
     * {@link EBatchOpType#UPDATE} and {@link EBatchOpType#DELETE} but should
     * not exist for {@link EBatchOpType#CREATE}. because it already has an
     * objectId.
     */
    public ParseBatch addObject(final ParseObject object,
            final EBatchOpType opType) throws ParseException {
        return addObjects(Arrays.asList(object), opType);
    }

    /**
     * Adds multiple objects to the batch to be {@link #execute() executed}.
     *
     * @param objects The objects to be added to the batch.
     * @param opType The type of operation to be performed on ALL
     * {@code objects}.
     * @return {@code this} to enable chaining.
     * @throws ParseException if any of the objects does not meet the
     * constraints for {@code opType}, for example, an objectId is required for
     * {@link EBatchOpType#UPDATE} and {@link EBatchOpType#DELETE} but should
     * not exist for {@link EBatchOpType#CREATE}. because it already has an
     * objectId.
     */
    public ParseBatch addObjects(final Collection<? extends ParseObject> objects,
            final EBatchOpType opType) throws ParseException {

        final String urlPath =  StringUtil.replaceAll(Util.getURLPath(Parse.getApiEndpoint()), "/", "");
        final String pathPrefix = "/" + (!Parse.isEmpty(urlPath) ? urlPath + "/" : "");

        final String method = opTypeToHttpMethod(opType);
        
        for (ParseObject object : objects) {
            validate(object, opType);
            final JSONObject objData = new JSONObject();
            try {
                objData.put("method", method);
                objData.put("path", pathPrefix + getObjectPath(object, opType));
                objData.put("body", object.getParseData());
            } catch (JSONException ex) {
                throw new ParseException(ParseException.INVALID_JSON, 
                        ParseException.ERR_PREPARING_REQUEST, ex);
            }
            data.put(objData);
            parseObjects.add(object);
        }
        return this;
    }

    /**
     * Executes the batch operation. 
     * <p>
     * All ParseObjects in the batch for which the 
     * requested operation was successful will also be updated with the response 
     * received from the server.
     *
     * @return {@code true} if the all the operations in the batch are
     * successfully executed. This is the same value returned by
     * {@link #isSucceeded()}.
     * @throws ParseException if executing the batch operation itself fails, for
     * example due to batch size exceeding limit.
     */
    public boolean execute() throws ParseException {
        
        final ParseCommand command = new ParsePostCommand("batch");
        final JSONObject payload = new JSONObject();
        try {
            payload.put("requests", data);
        } catch (JSONException ex) {
            throw new ParseException(ParseException.INVALID_JSON, 
                    ParseException.ERR_PREPARING_REQUEST, ex);
        }
        
        command.setMessageBody(payload);
        ParseResponse response = command.perform();
        if (!response.isFailed()) {
            processParseResponse(response);
        } else {
            succeeded = false;
            LOGGER.error("Request failed.");
            throw response.getException();
        }
        return isSucceeded();
    }

    /**
     * Indicates the status of the batch operation.
     *
     * @return {@code true} if the requested command is successfully performed
     * on each object in the batch; otherwise returns {@code false}.
     */
    public boolean isSucceeded() {
        return succeeded;
    }

    /**
     * Retrieves the error returned per ParseObject by the Parse server when the
     * batch was executed.
     *
     * @return A read-only map of all ParseObjects in the batch to result returned 
     * by the server. A null value indicates that the operation was successful 
     * for that key (ParseObject).
     * @throws ParseException if this method is invoked before {@link #execute()}.
     */
    public Map<ParseObject, ParseException> getErrors() throws ParseException {
        
        if (results == null) {
            throw new ParseException(ParseException.OTHER_CAUSE,
                    "The batch must first be executed");
        }
        
        Map<ParseObject, ParseException> map = new HashMap<ParseObject, ParseException>();
        for (int i = 0; i < parseObjects.size(); ++i) {
            map.put(parseObjects.get(i), results.get(i));
        }
        return Collections.unmodifiableMap(map);
    }

    private static String opTypeToHttpMethod(final EBatchOpType opType)
        throws ParseException {
        
        final String method;
        switch (opType) {
            case DELETE:
                method = "DELETE";
                break;
            case UPDATE:
                method = "PUT";
                break;
            case CREATE:
                method = "POST";
                break;
            default:
                throw new ParseException(ParseException.OPERATION_FORBIDDEN,
                        "Unknown/unsupported opType: " + opType);
        }
        return method;
    }
    
    private static String getObjectPath(final ParseObject object, final EBatchOpType opType) {
        String endpoint = object.getEndPoint();
        if (opType != EBatchOpType.CREATE) {
            endpoint += "/" + object.getObjectId();
        }

        return endpoint;
    }
    
    private ParseBatch() {
        parseObjects = new ArrayList<ParseObject>();
        data = new JSONArray();
    }

    /**
     * Checks if the provided object meets the constraints for the requested operation.
     * @param object The object to be validated.
     * @param opType The kind of operation to be performed on {@code object}.
     * @throws ParseException if the validation fails.
     */
    private static void validate(final ParseObject object, final EBatchOpType opType)
            throws ParseException {
        
        switch (opType) {
            case DELETE: // Deliberate fallthrough
            case UPDATE:
                if (object.getObjectId() == null) {
                    throw new ParseException(ParseException.OPERATION_FORBIDDEN,
                            "Cannot update or delete an object without an objectId.");
                }
                break;
            case CREATE:
                if (object.getObjectId() != null) {
                    throw new ParseException(ParseException.OPERATION_FORBIDDEN,
                            "Cannot create an object already having an objectId.");
                }
                break;
            default:
                throw new ParseException(ParseException.OPERATION_FORBIDDEN,
                        "Unknown/unsupported opType: " + opType);
        }
    }
    
    /**
     * Processes the response received from the server after executing the batch.
     * @param response The response to be processed.
     * @throws ParseException if anything goes wrong.
     */
    private void processParseResponse(final ParseResponse response) throws ParseException {
        succeeded = false;
        results = null;
        
        JSONArray json;
        try {
            json = new JSONArray(new String(response.getResponseData()));
        } catch (JSONException ex) {
            throw new ParseException(
                    "Response could not be converted to a JSONArray", ex); 
        }
        
        if (json.length() != parseObjects.size()) {
            throw new ParseException(ParseException.OTHER_CAUSE,
                 ParseException.ERR_PROCESSING_RESPONSE, new IllegalStateException(
                "Incorrect batch result count. Expected " + parseObjects.size()
                + " results but found " + json.length()));
        }
        
        results = new ArrayList<ParseException>();
        for (int i = 0; i < json.length(); ++i) {
            try {
                JSONObject result = json.getJSONObject(i);
                if (result.has("success")) {
                    
                    results.add(null);
                    final ParseObject parseObject = parseObjects.get(i);
                    final JSONObject resultData = result.getJSONObject("success");
                    parseObject.setData(resultData);
                    
                    if (parseObject.getUpdatedAt() == null) {
                        parseObject.setUpdatedAt(parseObject.getCreatedAt());
                    }
                    
                    if (resultData.length() == 0) {
                        parseObject.reset();;
                    }
                    
                } else if (result.has("error")) {
                    results.add(ParseResponse.getParseError(result.getJSONObject("error")));
                } else {
                  throw new ParseException(ParseException.INVALID_JSON, 
                    "Result '" + result + "' at index " + i 
                            + " neither has a success nor error field");  
                }
            } catch (JSONException ex) {
                throw new ParseException(ParseException.INVALID_JSON, 
                        ParseException.ERR_PROCESSING_RESPONSE, ex);
            }
        }
        
        succeeded = true;
        for (ParseException ex: results) {
            if (ex != null) {
                succeeded = false;
                break;
            }
        }
    }
}