/* 
 * Copyright 2015 Adobe.
 *
 * 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.adobe.ags.curly.controller;

import com.adobe.ags.curly.ApplicationState;
import com.adobe.ags.curly.ConnectionManager;
import static com.adobe.ags.curly.Messages.*;
import com.adobe.ags.curly.model.ActionResult;
import com.adobe.ags.curly.model.ActionUtils;
import com.adobe.ags.curly.xml.Action;
import com.google.gson.internal.LinkedTreeMap;
import java.io.File;
import java.io.UnsupportedEncodingException;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpHead;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.FileEntity;
import org.apache.http.entity.mime.MultipartEntityBuilder;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;

public class ActionRunner implements Runnable {

    public static final String UTF8 = "UTF-8";

    public static enum HttpMethod {
        GET, POST, DELETE, HEAD, PUT, TRACE, CONNECT, OPTIONS
    };
    Map<String, List<String>> postVariables = new LinkedTreeMap<>();
    Map<String, List<String>> getVariables = new LinkedTreeMap<>();
    Map<String, String> requestHeaders = new LinkedTreeMap<>();
    Action action;
    String URL;
    HttpMethod httpMethod = HttpMethod.GET;
    String putFile = "";
    boolean httpMethodExplicitlySet = false;
    boolean multipart = false;
    ActionResult response;
    Function<Function<CloseableHttpClient, Optional<Exception>>, Optional<Exception>> processor;

    public ActionRunner(Function<Function<CloseableHttpClient, Optional<Exception>>, Optional<Exception>> processor, Action action, Map<String, String> variables) throws ParseException {
        this.processor = processor;
        parseCommand(action);
        applyVariables(variables);
        response = new ActionResult(this);
    }

    public Action getAction() {
        return action;
    }

    @Override
    public void run() {
        if (!ApplicationState.getInstance().runningProperty().get()) {
            response.setException(new Exception(ApplicationState.getMessage(ACTIVITY_TERMINATED)));
            return;
        }
        response.started().set(true);
        response.updateProgress(0.5);

        if (action.getDelay() > 0) {
            try {
                Thread.sleep(action.getDelay());
            } catch (InterruptedException ex) {
                response.setException(ex);
                return;
            }
        }

        HttpUriRequest request;
        try {
            switch (httpMethod) {
                case GET:
                    request = new HttpGet(getURL());
                    break;
                case HEAD:
                    request = new HttpHead(getURL());
                    break;
                case DELETE:
                    request = new HttpDelete(getURL());
                    break;
                case POST:
                    request = new HttpPost(getURL());
                    addPostParams((HttpPost) request);
                    break;
                case PUT:
                    request = new HttpPut(getURL());
                    ((HttpPut) request).setEntity(new FileEntity(new File(putFile)));
                    break;
                default:
                    throw new UnsupportedOperationException(ApplicationState.getMessage(UNSUPPORTED_METHOD_ERROR) + ": " + httpMethod.name());
            }

            addHeaders(request);
            Optional<Exception> ex = processor.apply((CloseableHttpClient client) -> {
                try (CloseableHttpResponse httpResponse = client.execute(request, ConnectionManager.getContext())) {                    
                    response.processHttpResponse(httpResponse, action.getResultType());
                    EntityUtils.consume(httpResponse.getEntity());
                    return Optional.empty();
                } catch (Exception e) {
                    return Optional.of(e);
                }
            });
            if (ex.isPresent()) {
                throw ex.get();
            }
        } catch (Exception ex) {
            Logger.getLogger(ActionRunner.class.getName()).log(Level.SEVERE, null, ex);
            response.setException(ex);
        } finally {
            response.updateProgress(1);
        }
    }

    private String getURL() throws URISyntaxException {
        StringBuilder urlBuilder = new StringBuilder();
        String URI = URL.contains("?") ? URL.substring(0, URL.indexOf('?')) : URL;
        URI = URI.replaceAll("\\s", "%20");
        urlBuilder.append(URI);
        final BooleanProperty hasQueryString = new SimpleBooleanProperty(URL.contains("?"));
        getVariables.forEach((key, values) -> {
            if (values != null) {
                values.forEach(value -> {
                    try {
                        urlBuilder.append(hasQueryString.get() ? "&" : "?")
                                .append(URLEncoder.encode(key, UTF8))
                                .append("=")
                                .append(URLEncoder.encode(value != null ? value : "", UTF8));
                        hasQueryString.set(false);
                    } catch (UnsupportedEncodingException ex) {
                        Logger.getLogger(ActionRunner.class.getName()).log(Level.SEVERE, null, ex);
                    }
                });
            }
        });
        return urlBuilder.toString();
    }

    private void addHeaders(HttpUriRequest request) {
        requestHeaders.forEach(request::setHeader);
    }

    private void addPostParams(HttpEntityEnclosingRequestBase request) throws UnsupportedEncodingException {
        final MultipartEntityBuilder multipartBuilder = MultipartEntityBuilder.create();
        List<NameValuePair> formParams = new ArrayList<>();
        postVariables.forEach((name, values) -> values.forEach(value -> {
            if (multipart) {
                if (value.startsWith("@")) {
                    File f = new File(value.substring(1));
                    multipartBuilder.addBinaryBody(name, f, ContentType.DEFAULT_BINARY, f.getName());
                } else {
                    multipartBuilder.addTextBody(name, value);
                }
            } else {
                formParams.add(new BasicNameValuePair(name, value));
            }
        }));
        if (multipart) {
            request.setEntity(multipartBuilder.build());
        } else {
            request.setEntity(new UrlEncodedFormEntity(formParams));
        }
    }

    private void parseCommand(Action action) throws ParseException {
        this.action = action;
        String commandStr = tokenizeParameters(action.getCommand());

        List<String> parts = splitByUnquotedSpaces(commandStr);
        URL = detokenizeParameters(parts.remove(parts.size() - 1));
        int offset = 0;
        for (int i = 0; i < parts.size(); i++) {
            String part = parts.get(i);
            if (part.startsWith("-")) {
                if (part.length() == 2 && i < parts.size() - 1) {
                    if (parseCmdParam(part.charAt(1), parts.get(i + 1), offset)) {
                        i++;
                    }
                } else {
                    parseCmdParam(part.charAt(1), part.substring(2), offset);
                }
            } else {
                throw new ParseException(ApplicationState.getMessage(UNKNOWN_PARAMETER) + ": " + part, offset);
            }
            offset += part.length() + 1;
        }
    }

    public static List<String> splitByUnquotedSpaces(String str) {
        List<String> list = new ArrayList<>();
        String token = "";
        boolean insideQuote = false;
        for (int i = 0; i < str.length(); i++) {
            char c = str.charAt(i);

            switch (c) {
                case '"':
                    insideQuote = !insideQuote;
                    break;
                case ' ':
                case '\t':
                case '\n':
                    if (!insideQuote) {
                        if (!token.isEmpty()) {
                            list.add(token);
                            token = "";
                        }
                        break;
                    }
                default:
                    token += c;
            }
        }
        if (!token.isEmpty()) {
            list.add(token);
        }
        return list;
    }

    private String tokenizeParameters(String str) {
        Set<String> variableTokens = ActionUtils.getVariableNames(action);
        int tokenCounter = 0;
        for (String var : variableTokens) {
            String varPattern = Pattern.quote("${") + var + "(\\|.*?)?" + Pattern.quote("}");
            str = str.replaceAll(varPattern, Matcher.quoteReplacement("${" + (tokenCounter++) + "}"));
        }
        return str;
    }

    private String detokenizeParameters(String str) {
        Set<String> variableTokens = ActionUtils.getVariableNames(action);
        int tokenCounter = 0;
        for (String var : variableTokens) {
            str = str.replaceAll(Pattern.quote("${" + (tokenCounter++) + "}"), Matcher.quoteReplacement("${" + var + "}"));
        }
        return str;
    }

    private boolean parseCmdParam(char command, String param, int offset) throws ParseException {
        switch (command) {
            case 'F':
                httpMethod = HttpMethod.POST;
                httpMethodExplicitlySet = true;
            case 'd':
                Map<String, List<String>> vars = postVariables;
                if (!httpMethodExplicitlySet) {
                    httpMethod = HttpMethod.POST;
                } else if (httpMethod != HttpMethod.POST) {
                    vars = getVariables;
                }
                int equals = param.indexOf('=');
                if (equals > -1) {
                    String fieldName = detokenizeParameters(param.substring(0, equals));
                    String value = equals < param.length() - 1 ? detokenizeParameters(param.substring(equals + 1)) : null;
                    if (command == 'F' && value != null && value.startsWith("@")) {
                        httpMethod = HttpMethod.POST;
                        multipart = true;
                    }
                    if (!vars.containsKey(fieldName)) {
                        vars.put(fieldName, new ArrayList<>());
                    }
                    vars.get(fieldName).add(value);
                } else {
                    throw new ParseException(ApplicationState.getMessage(MISSING_NVP_FORM_ERROR), offset + 1);
                }
                return true;
            case 'T':
                httpMethod = HttpMethod.PUT;
                multipart = false;
                putFile = detokenizeParameters(param);
                return true;
            case 'X':
                try {
                    httpMethod = HttpMethod.valueOf(param.toUpperCase());
                } catch (IllegalArgumentException ex) {
                    throw new ParseException(ApplicationState.getMessage(UNKNOWN_METHOD_ERROR) + " " + param, offset + 1);
                }
                return true;
            case 'h':
                String[] nvp = param.split(":\\s*");
                if (nvp.length != 2) {
                    throw new ParseException(ApplicationState.getMessage(MISSING_NVP_HEADER_ERROR), offset + 1);
                }
                requestHeaders.put(detokenizeParameters(nvp[0]), detokenizeParameters(nvp[1]));
                return true;
            case 'e':
                requestHeaders.put("referer", detokenizeParameters(param));
                return true;
            case 'u':
                // ignored parameterized options
                return true;
            case 'G':
                httpMethodExplicitlySet = true;
                httpMethod = HttpMethod.GET;
            case 'S':
            case '#':
            case 'v':
                //ignored no-parameter flags
                return false;
            default:
                throw new ParseException(ApplicationState.getMessage(UNKNOWN_PARAMETER) + ": " + command, offset);
        }
    }

    private void applyVariables(Map<String, String> variables) {
        Set<String> variableTokens = ActionUtils.getVariableNames(action);
        variableTokens.forEach((String originalName) -> {
            String[] parts = originalName.split("\\|");
            String var = parts[0];
            String replaceVar = Pattern.quote("${" + originalName + "}");
            String replace = variables.get(var) == null ? "" : variables.get(var);
            URL = URL.replaceAll(replaceVar, Matcher.quoteReplacement(replace));
            putFile = putFile.replaceAll(replaceVar, Matcher.quoteReplacement(replace));
        });
        applyMultiVariablesToMap(variables, postVariables);
        applyMultiVariablesToMap(variables, getVariables);
        applyVariablesToMap(variables, requestHeaders);
    }

    private void applyVariablesToMap(Map<String, String> variables, Map<String, String> target) {
        Set<String> variableTokens = ActionUtils.getVariableNames(action);

        Set removeSet = new HashSet<>();
        Map<String, String> newValues = new HashMap<>();

        target.forEach((paramName, paramValue) -> {
            StringProperty paramNameProperty = new SimpleStringProperty(paramName);
            variableTokens.forEach((String originalName) -> {
                String[] variableNameParts = originalName.split("\\|");
                String variableName = variableNameParts[0];
                String variableNameMatchPattern = Pattern.quote("${" + originalName + "}");
                String val = variables.get(variableName);
                if (val == null) {
                    val = "";
                }
                String variableValue = Matcher.quoteReplacement(val);
                //----
                String newParamValue = newValues.containsKey(paramNameProperty.get()) ? newValues.get(paramNameProperty.get()) : paramValue;
                String newParamName = paramNameProperty.get().replaceAll(variableNameMatchPattern, variableValue);
                paramNameProperty.set(newParamName);
                newParamValue = newParamValue.replaceAll(variableNameMatchPattern, variableValue);
                if (!newParamName.equals(paramName) || !newParamValue.equals(paramValue)) {
                    removeSet.add(paramNameProperty.get());
                    removeSet.add(paramName);
                    newValues.put(newParamName, newParamValue);
                }
            });
        });
        target.keySet().removeAll(removeSet);
        target.putAll(newValues);
    }

    private void applyMultiVariablesToMap(Map<String, String> variables, Map<String, List<String>> target) {
        Set<String> variableTokens = ActionUtils.getVariableNames(action);

        Map<String, List<String>> newValues = new HashMap<>();
        Set removeSet = new HashSet<>();

        target.forEach((paramName, paramValues) -> {
            StringProperty paramNameProperty = new SimpleStringProperty(paramName);
            variableTokens.forEach((String originalName) -> {
                String[] variableNameParts = originalName.split("\\|");
                String variableName = variableNameParts[0];
                String variableNameMatchPattern = Pattern.quote("${" + originalName + "}");
                String val = variables.get(variableName);
                if (val == null) {
                    val = "";
                }
                String variableValue = Matcher.quoteReplacement(val);
                String newParamName = paramNameProperty.get().replaceAll(variableNameMatchPattern, variableValue);
                removeSet.add(paramNameProperty.get());
                removeSet.add(paramName);
                if (newValues.get(paramNameProperty.get()) == null) {
                    newValues.put(paramNameProperty.get(), new ArrayList<>(paramValues.size()));
                }
                if (newValues.get(newParamName) == null) {
                    newValues.put(newParamName, new ArrayList<>(paramValues.size()));
                }
                List<String> newParamValues = newValues.get(paramNameProperty.get());
                for (int i = 0; i < paramValues.size(); i++) {
                    String newParamValue = newParamValues != null && newParamValues.size() > i && newParamValues.get(i) != null ? newParamValues.get(i) : paramValues.get(i);

                    // fix for removing JCR values (setting them to an empty
                    // string deletes them from the JCR)
                    if (null == newParamValue) {
                        newParamValue = "";
                    }

                    newParamValue = newParamValue.replaceAll(variableNameMatchPattern, variableValue);
                    if (newParamName.contains("/") && newParamValue.equals("@" + newParamName)) {
                        // The upload name should actually be the file name, not the full path of the file.
                        removeSet.add(newParamName);
                        newValues.remove(newParamName);
                        newParamName = newParamName.substring(newParamName.lastIndexOf("/") + 1);
                        newValues.put(newParamName, newParamValues);
                    }
                    if (newValues.get(newParamName).size() == i) {
                        newValues.get(newParamName).add(newParamValue);
                    } else {
                        newValues.get(newParamName).set(i, newParamValue);
                    }
                }
                if (!paramNameProperty.get().equals(newParamName)) {
                    newValues.remove(paramNameProperty.get());
                }
                paramNameProperty.set(newParamName);
            });
        });
        target.keySet().removeAll(removeSet);
        target.putAll(newValues);
    }
}