/*
 * 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.brooklyn.core.effector.http;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.Callable;

import org.apache.brooklyn.api.effector.Effector;
import org.apache.brooklyn.api.mgmt.Task;
import org.apache.brooklyn.config.ConfigKey;
import org.apache.brooklyn.core.config.ConfigKeys;
import org.apache.brooklyn.core.config.MapConfigKey;
import org.apache.brooklyn.core.effector.AddEffector;
import org.apache.brooklyn.core.effector.EffectorBody;
import org.apache.brooklyn.core.effector.Effectors.EffectorBuilder;
import org.apache.brooklyn.core.entity.EntityInitializers;
import org.apache.brooklyn.core.entity.trait.Startable;
import org.apache.brooklyn.core.sensor.Sensors;
import org.apache.brooklyn.util.collections.Jsonya;
import org.apache.brooklyn.util.core.config.ConfigBag;
import org.apache.brooklyn.util.core.task.Tasks;
import org.apache.brooklyn.util.exceptions.Exceptions;
import org.apache.brooklyn.util.http.executor.HttpConfig;
import org.apache.brooklyn.util.http.executor.HttpExecutor;
import org.apache.brooklyn.util.http.executor.HttpRequest;
import org.apache.brooklyn.util.http.executor.HttpResponse;
import org.apache.brooklyn.util.http.executor.UsernamePassword;
import org.apache.brooklyn.util.http.executor.apacheclient.HttpExecutorImpl;
import org.apache.brooklyn.util.text.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.annotations.Beta;
import com.google.common.base.Enums;
import com.google.common.base.Joiner;
import com.google.common.base.Optional;
import com.google.common.base.Throwables;
import com.google.common.io.ByteStreams;
import com.google.common.net.HttpHeaders;
import com.jayway.jsonpath.JsonPath;

/**
 * An {@link Effector} to invoke REST endpoints.
 *
 * It allows to specify the URI, the HTTP verb, credentials for authentication and HTTP headers.
 * 
 * It deals with some {@link HttpHeaders.CONTENT_TYPE} namely 'application/json' (as default) and 'application/x-www-form-urlencoded'. 
 * In the latter case, a map payload will be URLEncoded in a single string
 * 
 * With optional JSON_PATH config key, the effector will extract a section of the json response. 
 * 
 * Using JSON_PATHS_AND_SENSORS, it is possible to extract one or more values from a json response, and publish them in sensors
 */
@Beta
public final class HttpCommandEffector extends AddEffector {

    private static final Logger LOG = LoggerFactory.getLogger(HttpCommandEffector.class);

    public static final ConfigKey<String> EFFECTOR_URI = ConfigKeys.newStringConfigKey("uri");
    public static final ConfigKey<String> EFFECTOR_HTTP_VERB = ConfigKeys.newStringConfigKey("httpVerb");
    public static final ConfigKey<String> EFFECTOR_HTTP_USERNAME = ConfigKeys.newStringConfigKey("httpUsername");
    public static final ConfigKey<String> EFFECTOR_HTTP_PASSWORD = ConfigKeys.newStringConfigKey("httpPassword");
    public static final ConfigKey<Map<String, String>> EFFECTOR_HTTP_HEADERS = new MapConfigKey(String.class, "headers");
    public static final ConfigKey<Object> EFFECTOR_HTTP_PAYLOAD = ConfigKeys.newConfigKey(Object.class, "httpPayload");
    public static final ConfigKey<String> JSON_PATH = ConfigKeys.newStringConfigKey("jsonPath", "JSON path to select in HTTP response");
    public static final ConfigKey<Map<String, String>> JSON_PATHS_AND_SENSORS = new MapConfigKey(String.class, "jsonPathAndSensors", "json path selector and corresponding sensor name that will publish the json path extracted value");

    /**
     * @deprecated since 0.12.0
     */
    @Deprecated
    public static final ConfigKey<String> PUBLISH_SENSOR = ConfigKeys.newStringConfigKey("publishSensor", "Sensor name where to store json path extracted value");

    public static final String APPLICATION_JSON = "application/json";
    public static final String APPLICATION_X_WWW_FORM_URLENCODE = "application/x-www-form-urlencoded";

    private enum HttpVerb {
        GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE
    }

    public HttpCommandEffector(ConfigBag params) {
        super(newEffectorBuilder(params).build());
    }

    public static EffectorBuilder<String> newEffectorBuilder(ConfigBag params) {
        EffectorBuilder<String> eff = AddEffector.newEffectorBuilder(String.class, params);
        eff.impl(new Body(eff.buildAbstract(), params));
        return eff;
    }

    protected static class Body extends EffectorBody<String> {
        private final Effector<?> effector;
        private final ConfigBag params;

        public Body(Effector<?> eff, final ConfigBag params) {
            this.effector = eff;
            checkNotNull(params.getAllConfigRaw().get(EFFECTOR_URI.getName()), "uri must be supplied when defining this effector");
            checkNotNull(params.getAllConfigRaw().get(EFFECTOR_HTTP_VERB.getName()), "HTTP verb must be supplied when defining this effector");
            this.params = params;
        }

        @Override
        public String call(final ConfigBag params) {
            ConfigBag allConfig = ConfigBag.newInstanceCopying(this.params).putAll(params);
            final URI uri = convertToURI(EntityInitializers.resolve(allConfig, EFFECTOR_URI));
            final String httpVerb = isValidHttpVerb(EntityInitializers.resolve(allConfig, EFFECTOR_HTTP_VERB));
            final String httpUsername = EntityInitializers.resolve(allConfig, EFFECTOR_HTTP_USERNAME);
            final String httpPassword = EntityInitializers.resolve(allConfig, EFFECTOR_HTTP_PASSWORD);
            final Map<String, String> headers = EntityInitializers.resolve(allConfig, EFFECTOR_HTTP_HEADERS);
            final Object payload = EntityInitializers.resolve(allConfig, EFFECTOR_HTTP_PAYLOAD);
            final String jsonPath = EntityInitializers.resolve(allConfig, JSON_PATH);
            final String publishSensor = EntityInitializers.resolve(allConfig, PUBLISH_SENSOR);
            final Map<String, String> pathsAndSensors = EntityInitializers.resolve(allConfig, JSON_PATHS_AND_SENSORS);
            
            if(!Strings.isEmpty(jsonPath) && !pathsAndSensors.isEmpty()) {
                throw new IllegalArgumentException("Both jsonPath and pathsAndSensors are defined, please pick just one to resolve the ambiguity");
            }
            final HttpExecutor httpExecutor = HttpExecutorImpl.newInstance();

            final HttpRequest request = buildHttpRequest(httpVerb, uri, headers, httpUsername, httpPassword, payload);
            Task t = Tasks.builder().displayName(effector.getName()).body(new Callable<Object>() {
                @Override
                public Object call() throws Exception {
                    ByteArrayOutputStream out = new ByteArrayOutputStream();
                    try {
                        HttpResponse response = httpExecutor.execute(request);
                        validateResponse(response);
                        ByteStreams.copy(response.getContent(), out);
                        return new String(out.toByteArray());
                    } catch (IOException e) {
                        throw Exceptions.propagate(e);
                    }
                }
            }).build();

            String responseBody = (String) queue(t).getUnchecked();

            if (jsonPath != null) {
                String extractedValue = JsonPath.parse(responseBody).read(jsonPath, String.class);
                if (publishSensor != null) {
                    LOG.warn("`publishSensor` configuration key is deprecated. PLease prefer `pathsAndSensors`, instead");
                    entity().sensors().set(Sensors.newStringSensor(publishSensor), extractedValue);
                }
                return extractedValue;
            }
            
            if (!pathsAndSensors.isEmpty()) {
                for (String path : pathsAndSensors.keySet()) {
                    String jsonPathValue = JsonPath.parse(responseBody).read(path, String.class);
                    entity().sensors().set(Sensors.newStringSensor(pathsAndSensors.get(path)), jsonPathValue);
                }
            }
            // TODO responseBody or else ???
            return responseBody;
        }

        private URI convertToURI(String url) {
            try {
                return new URL(url).toURI();
            } catch (MalformedURLException e) {
                throw Exceptions.propagate(e);
            } catch (URISyntaxException e) {
                throw Exceptions.propagate(e);
            }
        }

        private void validateResponse(HttpResponse response) {
            int statusCode = response.code();
            if (statusCode == 401) {
                throw new RuntimeException("Authorization exception");
            } else if (statusCode == 404) {
                throw new RuntimeException("Resource not found");
            } else if (statusCode >= 500) {
                throw new RuntimeException("Server error");
            }
        }

        private HttpRequest buildHttpRequest(String httpVerb, URI uri, Map<String, String> headers, String httpUsername, String httpPassword, Object payload) {
            HttpRequest.Builder httpRequestBuilder = new HttpRequest.Builder()
                    .uri(uri)
                    .method(httpVerb)
                    .config(HttpConfig.builder()
                            .trustSelfSigned(true)
                            .trustAll(true)
                            .laxRedirect(true)
                            .build());

            if (headers != null) {
                httpRequestBuilder.headers(headers);
            }

            if (payload != null) {
                String body = "";
                String contentType = headers.get(HttpHeaders.CONTENT_TYPE);
                if (contentType == null || contentType.equalsIgnoreCase(APPLICATION_JSON)) {
                    LOG.warn("Content-Type not specified. Using {}, as default (continuing)", APPLICATION_JSON);
                    body = toJsonString(payload);
                } else if (contentType.equalsIgnoreCase(APPLICATION_X_WWW_FORM_URLENCODE)) {
                    if (payload instanceof Map) {
                        for (Map.Entry<String, String> entry : ((Map<String, String>) payload).entrySet()) {
                            try {
                                if (!body.equals("")) body += "&";
                                body += URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8.toString()) + "=" + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8.toString());
                            } catch (UnsupportedEncodingException e) {
                                throw Throwables.propagate(e);
                            }

                        }
                    }
                } else if (!contentType.equalsIgnoreCase(APPLICATION_X_WWW_FORM_URLENCODE) && !contentType.equalsIgnoreCase(APPLICATION_JSON)) {
                    LOG.warn("the http request may fail with payload {} and 'Content-Type= {}, (continuing)", payload, contentType);
                    body = payload.toString();
                }
                httpRequestBuilder.body(body.getBytes());
            }

            if (httpUsername != null && httpPassword != null) {
                httpRequestBuilder.credentials(new UsernamePassword(httpUsername, httpPassword));
            }

            return httpRequestBuilder.build();
        }

        private String isValidHttpVerb(String httpVerb) {
            Optional<HttpVerb> state = Enums.getIfPresent(HttpVerb.class, httpVerb.toUpperCase());
            checkArgument(state.isPresent(), "Expected one of %s but was %s", Joiner.on(',').join(HttpVerb.values()), httpVerb);
            return httpVerb;
        }

        private String toJsonString(Object payload) {
            return Jsonya.newInstance().add(payload).toString();
        }

    }
}
