/* 
 * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
 * the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
 * 
 * Copyright (c) 2014 Digi International Inc., All Rights Reserved.
 */
 
package com.digi.wva.internal;

import android.util.Log;

import com.digi.wva.exc.WvaHttpException;
import com.digi.wva.exc.WvaHttpException.WvaHttpBadRequest;
import com.digi.wva.exc.WvaHttpException.WvaHttpForbidden;
import com.digi.wva.exc.WvaHttpException.WvaHttpInternalServerError;
import com.digi.wva.exc.WvaHttpException.WvaHttpNotFound;
import com.digi.wva.exc.WvaHttpException.WvaHttpRequestUriTooLong;
import com.digi.wva.exc.WvaHttpException.WvaHttpServiceUnavailable;
import com.squareup.okhttp.Callback;
import com.squareup.okhttp.Credentials;
import com.squareup.okhttp.MediaType;
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.RequestBody;
import com.squareup.okhttp.Response;

import org.json.JSONException;
import org.json.JSONObject;

import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Locale;

import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.X509TrustManager;

/**
 * The abstraction layer between HTTP operations by this library and the underlying HTTP services.
 */
@SuppressWarnings("UnusedDeclaration")
public class HttpClient {
	
	/**
	 * Wrapper around OkHttp's {@link Callback} interface, specifically targeted at
     * expecting and handling JSON response bodies.
	 */
	public static abstract class HttpCallback {
		/**
		 * Called when an HTTP response was successfully returned from the WVA,
		 * and the response was successfully parsed as a JSON object.
		 * 
		 * @param response the JSON object from the HTTP response
		 */
		public abstract void onSuccess(JSONObject response);
		
		/**
         * Called when the HTTP request failed or had an error response.
         *
		 * Called in any of these cases:
		 * <ul>
		 * 	<li>The request could not be executed due to cancellation, a connectivity problem, or a timeout.</li>
		 * 	<li>An HTTP response was received, but its status code did not indicate success (e.g. 404, 500).</li>
		 * 	<li>An HTTP response was received, but its body could not be parsed as a JSON object.</li>
		 * </ul>
		 * 
		 * @param error the {@link Throwable} causing the failure
		 */
		public abstract void onFailure(Throwable error);
		
		/**
		 * Invoked when the HTTP request was successful, but the response body could not
         * be parsed as a JSON object. The default implementation logs the error and
         * invokes {@link #onFailure(Throwable) onFailure(Throwable)}.
		 * 
		 * @param error the {@link JSONException} raised while parsing the response body
         * @param rawBody the body which could not be parsed as JSON
		 */
		public void onJsonParseError(JSONException error, String rawBody) {
			String errmsg = "Error parsing response as JSON: " + error.getMessage();
			Log.e("HttpCallback", errmsg + "\n" + rawBody);
			this.onFailure(error);
		}
	}
	
	/**
	 * Version of {@link HttpCallback} which expects the HTTP response body to be empty.
	 * Useful as a callback for requests which do not send a body back (such as performing
	 * the PUT requests for subscriptions, etc.).
	 */
	public static abstract class ExpectEmptyCallback extends HttpCallback {
		/**
		 * Calls {@link #onBodyNotEmpty(String)}, with <b>response.toString()</b>.
         *
         * <p>This method is final so as to minimize user error.</p>
		 */
        @Override
		public final void onSuccess(JSONObject response) {
			// Response was JSON. We did not expect this.
			Log.w("ExpectEmptyCallback", "Got a JSON response.");
			onBodyNotEmpty(response.toString());
		}
		
		/**
		 * Called when the HTTP response was considered successful (status code in the 200s),
		 * but the body was not empty.
		 * 
		 * @param body the HTTP response body
		 */
		public abstract void onBodyNotEmpty(String body);
		
		/**
		 * Called when the HTTP response was successfully received from the WVA,
		 * and the response body was empty (as expected).
		 */
		public abstract void onSuccess();

        /**
         * Invoked when the HTTP request was successful, but the response body could not
         * be parsed as a JSON object.
         *
         * <p>This class makes this method final to minimize user error, because this is the point
         * where empty response bodies are handled (so as to call {@link #onSuccess()}).</p>
         *
         * @param error the {@link JSONException} raised while parsing the response body
         * @param rawBody the body which could not be parsed as JSON
         */
		@Override
		public final void onJsonParseError(JSONException error, String rawBody) {
			if (rawBody != null && rawBody.trim().length() == 0) {
				// JSON parsing failed because there was no content in the body. We're okay with this.
				onSuccess();
			} else {
				onBodyNotEmpty(rawBody);
			}
		}
	}
	
	private static final String TAG = "com.digi.wva.internal.HttpClient";
	private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");

	/**
	 * Provides the basic format of a WVA web service resource.
	 * For instance: http://192.168.0.3/ws/vehicle/EngineSpeed
	 */
	private String credentials;
	private int httpPort = 80, httpsPort = 443;
	private boolean useSecureHttp;
	
	private final String hostname;
	private final OkHttpClient client;
	
	/**
	 * Returns an SSLSocketFactory which trusts any certificate. (Needed in order to connect
	 * with the WVA when using HTTPS.)
	 * @return an SSLSocketFactory which trusts all certificates
	 */
	private static SSLSocketFactory makeSSLSocketFactory() {
		// based on information from:
		// http://engineering.sproutsocial.com/2013/09/android-using-volley-and-loopj-with-self-signed-certificates/
		try {
            SSLContext context = SSLContext.getInstance("TLS");
            context.init(null, new X509TrustManager[]{new X509TrustManager() {
                @Override
                public X509Certificate[] getAcceptedIssuers() {
                    return null;
                }

                @Override
                public void checkServerTrusted(X509Certificate[] chain, String authType)
                        throws CertificateException {
                }

                @Override
                public void checkClientTrusted(X509Certificate[] chain, String authType)
                        throws CertificateException {
                }
            }}, null);

            return context.getSocketFactory();
        } catch (NoSuchAlgorithmException e) {
            return null;
		} catch (KeyManagementException e) {
            return null;
        }
	}

    /** Constructor
     *
     * @param hostname The hostname/IP address of the WVA.
     */
	public HttpClient(String hostname) {
		this.client = new OkHttpClient();
		client.setSslSocketFactory(makeSSLSocketFactory())
			  .setHostnameVerifier(new HostnameVerifier() {
				public boolean verify(String hostname, SSLSession session) {
					return true;
				}
			});
		this.hostname = hostname;
	}

    /**
     * Create a new {@link com.squareup.okhttp.Request.Builder Request.Builder} for accessing the
     * given URL, and adds the {@code Accept: application/json} header, as well as an
     * {@code Authorization} header if authentication is being used.
     *
     * <p>This method is protected, rather than private, due to a bug between JaCoCo and
     * the Android build tools which causes the instrumented bytecode to be invalid when this
     * method is private:
     * <a href="http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode" target="_blank">see StackOverflow question.</a>
     * </p>
     *
     * @param url the path, relative to {@code /ws/}, on which to perform a request
     * @return a corresponding {@link com.squareup.okhttp.Request.Builder Request.Builder} object
     */
	protected Request.Builder makeBuilder(String url) {
		Request.Builder builder = new Request.Builder().url(getAbsoluteUrl(url)).header("Accept", "application/json");
		if (credentials != null) {
			builder.header("Authorization", credentials);
		}
		return builder;
	}

    /**
     * Converts a JSONObject, to be used as the body of a request, to its corresponding
     * {@link RequestBody} representation.
     *
     * <p>Used by {@link #post} and {@link #put}, as well as their synchronous variants.</p>
     *
     * <p>This method is protected, rather than private, due to a bug between JaCoCo and
     * the Android build tools which causes the instrumented bytecode to be invalid when this
     * method is private:
     * <a href="http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode" target="_blank">see StackOverflow question.</a>
     * </p>
     *
     * @param obj the JSON data to be used
     * @return the request body
     */
	protected RequestBody makeBody(JSONObject obj) {
		return obj == null ? null : RequestBody.create(JSON, obj.toString());
	}

    /**
     * Wrap an HttpCallback in an OkHttp {@link Callback} instance.
     * Also will automatically attempt to parse the response as JSON.
     *
     * <p>This method is protected, rather than private, due to a bug between JaCoCo and
     * the Android build tools which causes the instrumented bytecode to be invalid when this
     * method is private:
     * <a href="http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode" target="_blank">see StackOverflow question.</a>
     * </p>

     * @param callback the {@link com.digi.wva.internal.HttpClient.HttpCallback} to wrap
     * @return a corresponding {@link Callback}
     */
    protected Callback wrapCallback(final HttpCallback callback) {
		return new Callback() {
			@Override
			public void onResponse(Response response) throws IOException {
                Request request = response.request();
                Log.i(TAG, "\u2190 " + response.code() + " " + request.method() + " " + request.urlString());
				String responseBody = response.body().string();
				if (response.isSuccessful()) {
					// Request succeeded. Parse JSON response.
					try {
						JSONObject parsed = new JSONObject(responseBody);
						callback.onSuccess(parsed);
					} catch (JSONException e) {
						callback.onJsonParseError(e, responseBody);
					}
				} else {
                    int status = response.code();
                    String url = response.request().urlString();
                    Exception error;

                    // Generate an appropriate exception based on the status code
                    switch (status) {
                        case 400:
                            error = new WvaHttpBadRequest(url, responseBody);
                            break;
                        case 403:
                            error = new WvaHttpForbidden(url, responseBody);
                            break;
                        case 404:
                            error = new WvaHttpNotFound(url, responseBody);
                            break;
                        case 414:
                            error = new WvaHttpRequestUriTooLong(url, responseBody);
                            break;
                        case 500:
                            error = new WvaHttpInternalServerError(url, responseBody);
                            break;
                        case 503:
                            error = new WvaHttpServiceUnavailable(url, responseBody);
                            break;
                        default:
                            error = new WvaHttpException("HTTP " + status, url, responseBody);
                            break;
                    }

                    callback.onFailure(error);
				}
			}
			
			@Override
			public void onFailure(Request request, IOException exception) {
				callback.onFailure(exception);
			}
		};
	}

    /**
     * Asynchronously perform an HTTP GET request on the given path.
     *
     * @param url the path, relative to {@code /ws/}, on which to perform a GET request
     * @param callback a callback for when the request completes or is in error
     */
	public void get(String url, HttpCallback callback) {
		Request request = makeBuilder(url).get().build();
        logRequest(request);
        client.newCall(request).enqueue(wrapCallback(callback));
	}

    /**
     * Asynchronously perform an HTTP POST request on the given path,
     * with the given JSON data.
     *
     * @param url the path, relative to {@code /ws/}, on which to perform a POST request
     * @param obj the JSON object to POST to the device
     * @param callback a callback for when the request completes or is in error
     */
	public void post(String url, JSONObject obj, HttpCallback callback) {
		Request request = this.makeBuilder(url).post(this.makeBody(obj)).build();
        logRequest(request);
		client.newCall(request).enqueue(wrapCallback(callback));
	}

    /**
     * Asynchronously perform an HTTP PUT request on the given path,
     * with the given JSON data.
     *
     * @param url the path, relative to {@code /ws/}, on which to perform a PUT request
     * @param obj the JSON object to PUT to the device
     * @param callback a callback for when the request completes or is in error
     */
	public void put(String url, JSONObject obj, HttpCallback callback) {
		Request request = this.makeBuilder(url).put(this.makeBody(obj)).build();
        logRequest(request);
		client.newCall(request).enqueue(wrapCallback(callback));
	}

    /**
     * Asynchronously perform an HTTP DELETE request on the given path.
     *
     * @param url the path, relative to {@code /ws/}, on which to perform a DELETE request
     * @param callback a callback for when the request completes or is in error
     */
	public void delete(String url, HttpCallback callback) {
        Request request = this.makeBuilder(url).delete().build();
        logRequest(request);
        client.newCall(request).enqueue(wrapCallback(callback));
	}

    /**
     * Log information of OkHttp Request objects
     *
     * <p>This method is protected, rather than private, due to a bug between JaCoCo and
     * the Android build tools which causes the instrumented bytecode to be invalid when this
     * method is private:
     * <a href="http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode" target="_blank">see StackOverflow question.</a>
     * </p>
     */
    protected void logRequest(Request request) {
        Log.i(TAG, "\u2192 " + request.method() + " " + request.urlString());
    }

    /**
     * Given a relative path (such as {@code "config"}), return a {@link URL} object representing
     * the corresponding full path in the WVA's web services (such as
     * {@code "http://192.168.100.1/ws/config"}).
     *
     * <p>This method is used internally by {@link HttpClient} when constructing its HTTP requests.</p>
     *
     * @param relativePath the web services path to use
     * @return a {link URL} representing the full path to <b>relativePath</b>
     */
	public URL getAbsoluteUrl(String relativePath) {
		String scheme = (this.useSecureHttp ? "https" : "http");
		int port = (this.useSecureHttp ? httpsPort : httpPort);

        try {
            return new URL(scheme, this.hostname, port, "/ws/" + relativePath);
        } catch (MalformedURLException e) {
            String formatted = String.format(Locale.US, "%s://%s:%d/ws/%s", scheme, this.hostname, port, relativePath);
            Log.wtf(TAG, "Malformed URL: " + formatted);
            throw new AssertionError("Malformed URL: " + formatted);
        }
    }
	
	/**
	 * Sets the basic authentication parameters to be added to every HTTP request made by this client.
     *
	 * @param username the username for authentication
	 * @param password the password for authentication
	 */
	public void useBasicAuth(String username, String password) {
		credentials = Credentials.basic(username, password);
	}
	
	/**
	 * Clears any previously-set basic authentication for HTTP requests.
	 */
	public void clearBasicAuth() {
		credentials = null;
	}
	
	/**
	 * Sets whether this HTTP client should communicate over HTTP or HTTPS.
	 * @param secure true to use HTTPS, false to use HTTP
	 */
	public void useSecureHttp(boolean secure) {
		this.useSecureHttp = secure;
	}
	
	/**
	 * Sets the port to be used when making HTTP requests.
     *
	 * @param port the port to use for HTTP
	 */
	public void setHttpPort(int port) {
		this.httpPort = port;
	}
	
	/**
	 * Sets the port to be used when making HTTPS requests.
     *
	 * @param port the port to use for HTTPS
	 */
	public void setHttpsPort(int port) {
		this.httpsPort = port;
	}

    /**
     * Gets the underlying HTTP client used by this HttpClient instance.
     *
     * @return the underlying HTTP client
     */
    OkHttpClient getUnderlyingClient() {
        return this.client;
    }
}

