/*******************************************************************************
 *  Copyright (c) 2011 GitHub Inc.
 *  All rights reserved. This program and the accompanying materials
 *  are made available under the terms of the Eclipse Public License v1.0
 *  which accompanies this distribution, and is available at
 *  http://www.eclipse.org/legal/epl-v10.html
 *
 *  Contributors:
 *    Kevin Sawicki (GitHub Inc.) - initial API and implementation
 *    Christian Trutz             - HttpClient 4.1
 *******************************************************************************/
package org.eclipse.egit.github.core.client;

import static com.google.gson.stream.JsonToken.BEGIN_ARRAY;
import static java.net.HttpURLConnection.HTTP_ACCEPTED;
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
import static java.net.HttpURLConnection.HTTP_CONFLICT;
import static java.net.HttpURLConnection.HTTP_CREATED;
import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
import static java.net.HttpURLConnection.HTTP_GONE;
import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
import static java.net.HttpURLConnection.HTTP_OK;
import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
import static org.eclipse.egit.github.core.client.IGitHubConstants.AUTH_TOKEN;
import static org.eclipse.egit.github.core.client.IGitHubConstants.CHARSET_UTF8;
import static org.eclipse.egit.github.core.client.IGitHubConstants.CONTENT_TYPE_JSON;
import static org.eclipse.egit.github.core.client.IGitHubConstants.HOST_API;
import static org.eclipse.egit.github.core.client.IGitHubConstants.HOST_DEFAULT;
import static org.eclipse.egit.github.core.client.IGitHubConstants.HOST_GISTS;
import static org.eclipse.egit.github.core.client.IGitHubConstants.PROTOCOL_HTTPS;
import static org.eclipse.egit.github.core.client.IGitHubConstants.SEGMENT_V3_API;

import com.google.gson.Gson;
import com.google.gson.JsonParseException;
import com.google.gson.stream.JsonReader;

import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Type;
import java.net.HttpURLConnection;
import java.net.URL;

import org.eclipse.egit.github.core.RequestError;
import org.eclipse.egit.github.core.util.EncodingUtils;

/**
 * Client class for interacting with GitHub HTTP/JSON API.
 */
public class GitHubClient {

	/**
	 * Create API v3 client from URL.
	 * <p>
	 * This creates an HTTPS-based client with a host that contains the host
	 * value of the given URL prefixed with 'api' if the given URL is github.com
	 * or gist.github.com
	 *
	 * @param url
	 * @return client
	 */
	public static GitHubClient createClient(String url) {
		try {
			String host = new URL(url).getHost();
			if (HOST_DEFAULT.equals(host) || HOST_GISTS.equals(host))
				host = HOST_API;
			return new GitHubClient(host);
		} catch (IOException e) {
			throw new IllegalArgumentException(e);
		}
	}

	/**
	 * Content-Type header
	 */
	protected static final String HEADER_CONTENT_TYPE = "Content-Type"; //$NON-NLS-1$

	/**
	 * Accept header
	 */
	protected static final String HEADER_ACCEPT = "Accept"; //$NON-NLS-1$

	/**
	 * Authorization header
	 */
	protected static final String HEADER_AUTHORIZATION = "Authorization"; //$NON-NLS-1$

	/**
	 * User-Agent header
	 */
	protected static final String HEADER_USER_AGENT = "User-Agent"; //$NON-NLS-1$

	/**
	 * METHOD_GET
	 */
	protected static final String METHOD_GET = "GET"; //$NON-NLS-1$

	/**
	 * METHOD_PUT
	 */
	protected static final String METHOD_PUT = "PUT"; //$NON-NLS-1$

	/**
	 * METHOD_POST
	 */
	protected static final String METHOD_POST = "POST"; //$NON-NLS-1$

	/**
	 * METHOD_DELETE
	 */
	protected static final String METHOD_DELETE = "DELETE"; //$NON-NLS-1$

	/**
	 * Default user agent request header value
	 */
	protected static final String USER_AGENT = "GitHubJava/2.1.0"; //$NON-NLS-1$

	/**
	 * 422 status code for unprocessable entity
	 */
	protected static final int HTTP_UNPROCESSABLE_ENTITY = 422;

	/**
	 * Base URI
	 */
	protected final String baseUri;

	/**
	 * Prefix to apply to base URI
	 */
	protected final String prefix;

	/**
	 * {@link Gson} instance
	 */
	protected Gson gson = GsonUtils.getGson();

	private String user;

	private String credentials;

	private String userAgent = USER_AGENT;

	private int bufferSize = 8192;

	private int requestLimit = -1;

	private int remainingRequests = -1;

	/**
	 * Create default client
	 */
	public GitHubClient() {
		this(HOST_API);
	}

	/**
	 * Create client for host name
	 *
	 * @param hostname
	 */
	public GitHubClient(String hostname) {
		this(hostname, -1, PROTOCOL_HTTPS);
	}

	/**
	 * Create client for host, port, and scheme
	 *
	 * @param hostname
	 * @param port
	 * @param scheme
	 */
	public GitHubClient(final String hostname, final int port,
			final String scheme) {
		final StringBuilder uri = new StringBuilder(scheme);
		uri.append("://"); //$NON-NLS-1$
		uri.append(hostname);
		if (port > 0)
			uri.append(':').append(port);
		baseUri = uri.toString();

		// Use URI prefix on non-standard host names
		if (HOST_API.equals(hostname))
			prefix = null;
		else
			prefix = SEGMENT_V3_API;
	}

	/**
	 * Set whether or not serialized data should include fields that are null.
	 *
	 * @param serializeNulls
	 * @return this client
	 */
	public GitHubClient setSerializeNulls(boolean serializeNulls) {
		gson = GsonUtils.getGson(serializeNulls);
		return this;
	}

	/**
	 * Set the value to set as the user agent header on every request created.
	 * Specifying a null or empty agent parameter will reset this client to use
	 * the default user agent header value.
	 *
	 * @param agent
	 * @return this client
	 */
	public GitHubClient setUserAgent(final String agent) {
		if (agent != null && agent.length() > 0)
			userAgent = agent;
		else
			userAgent = USER_AGENT;
		return this;
	}

	/**
	 * Configure request with standard headers
	 *
	 * @param request
	 * @return configured request
	 */
	protected HttpURLConnection configureRequest(final HttpURLConnection request) {
		if (credentials != null)
			request.setRequestProperty(HEADER_AUTHORIZATION, credentials);
		request.setRequestProperty(HEADER_USER_AGENT, userAgent);
		request.setRequestProperty(HEADER_ACCEPT,
				"application/vnd.github.beta+json"); //$NON-NLS-1$
		return request;
	}

	/**
	 * Configure request URI
	 *
	 * @param uri
	 * @return configured URI
	 */
	protected String configureUri(final String uri) {
		if (prefix == null || uri.startsWith(prefix))
			return uri;
		else
			return prefix + uri;
	}

	/**
	 * Create connection to URI
	 *
	 * @param uri
	 * @return connection
	 * @throws IOException
	 */
	protected HttpURLConnection createConnection(String uri) throws IOException {
		URL url = new URL(createUri(uri));
		return (HttpURLConnection) url.openConnection();
	}

	/**
	 * Create connection to URI
	 *
	 * @param uri
	 * @param method
	 * @return connection
	 * @throws IOException
	 */
	protected HttpURLConnection createConnection(String uri, String method)
			throws IOException {
		HttpURLConnection connection = createConnection(uri);
		connection.setRequestMethod(method);
		return configureRequest(connection);
	}

	/**
	 * Create a GET request connection to the URI
	 *
	 * @param uri
	 * @return connection
	 * @throws IOException
	 */
	protected HttpURLConnection createGet(String uri) throws IOException {
		return createConnection(uri, METHOD_GET);
	}

	/**
	 * Create a POST request connection to the URI
	 *
	 * @param uri
	 * @return connection
	 * @throws IOException
	 */
	protected HttpURLConnection createPost(String uri) throws IOException {
		return createConnection(uri, METHOD_POST);
	}

	/**
	 * Create a PUT request connection to the URI
	 *
	 * @param uri
	 * @return connection
	 * @throws IOException
	 */
	protected HttpURLConnection createPut(String uri) throws IOException {
		return createConnection(uri, METHOD_PUT);
	}

	/**
	 * Create a DELETE request connection to the URI
	 *
	 * @param uri
	 * @return connection
	 * @throws IOException
	 */
	protected HttpURLConnection createDelete(String uri) throws IOException {
		return createConnection(uri, METHOD_DELETE);
	}

	/**
	 * Set credentials
	 *
	 * @param user
	 * @param password
	 * @return this client
	 */
	public GitHubClient setCredentials(final String user, final String password) {
		this.user = user;
		if (user != null && user.length() > 0 && password != null
				&& password.length() > 0)
			credentials = "Basic " //$NON-NLS-1$
					+ EncodingUtils.toBase64(user + ':' + password);
		else
			credentials = null;
		return this;
	}

	/**
	 * Set OAuth2 token
	 *
	 * @param token
	 * @return this client
	 */
	public GitHubClient setOAuth2Token(String token) {
		if (token != null && token.length() > 0)
			credentials = AUTH_TOKEN + ' ' + token;
		else
			credentials = null;
		return this;
	}

	/**
	 * Set buffer size used to send the request and read the response
	 *
	 * @param bufferSize
	 * @return this client
	 */
	public GitHubClient setBufferSize(int bufferSize) {
		if (bufferSize < 1)
			throw new IllegalArgumentException(
					"Buffer size must be greater than zero"); //$NON-NLS-1$

		this.bufferSize = bufferSize;
		return this;
	}

	/**
	 * Get the user that this client is currently authenticating as
	 *
	 * @return user or null if not authentication
	 */
	public String getUser() {
		return user;
	}

	/**
	 * Convert object to a JSON string
	 *
	 * @param object
	 * @return JSON string
	 * @throws IOException
	 */
	protected String toJson(Object object) throws IOException {
		try {
			return gson.toJson(object);
		} catch (JsonParseException jpe) {
			IOException ioe = new IOException(
					"Parse exception converting object to JSON"); //$NON-NLS-1$
			ioe.initCause(jpe);
			throw ioe;
		}
	}

	/**
	 * Parse JSON to specified type
	 *
	 * @param <V>
	 * @param stream
	 * @param type
	 * @return parsed type
	 * @throws IOException
	 */
	protected <V> V parseJson(InputStream stream, Type type) throws IOException {
		return parseJson(stream, type, null);
	}

	/**
	 * Parse JSON to specified type
	 *
	 * @param <V>
	 * @param stream
	 * @param type
	 * @param listType
	 * @return parsed type
	 * @throws IOException
	 */
	protected <V> V parseJson(InputStream stream, Type type, Type listType)
			throws IOException {
		BufferedReader reader = new BufferedReader(new InputStreamReader(
				stream, CHARSET_UTF8), bufferSize);
		if (listType == null)
			try {
				return gson.fromJson(reader, type);
			} catch (JsonParseException jpe) {
				IOException ioe = new IOException(
						"Parse exception converting JSON to object"); //$NON-NLS-1$
				ioe.initCause(jpe);
				throw ioe;
			} finally {
				try {
					reader.close();
				} catch (IOException ignored) {
					// Ignored
				}
			}
		else {
			JsonReader jsonReader = new JsonReader(reader);
			try {
				if (jsonReader.peek() == BEGIN_ARRAY)
					return gson.fromJson(jsonReader, listType);
				else
					return gson.fromJson(jsonReader, type);
			} catch (JsonParseException jpe) {
				IOException ioe = new IOException(
						"Parse exception converting JSON to object"); //$NON-NLS-1$
				ioe.initCause(jpe);
				throw ioe;
			} finally {
				try {
					jsonReader.close();
				} catch (IOException ignored) {
					// Ignored
				}
			}
		}
	}

	/**
	 * Does status code denote an error
	 *
	 * @param code
	 * @return true if error, false otherwise
	 */
	protected boolean isError(final int code) {
		switch (code) {
		case HTTP_BAD_REQUEST:
		case HTTP_UNAUTHORIZED:
		case HTTP_FORBIDDEN:
		case HTTP_NOT_FOUND:
		case HTTP_CONFLICT:
		case HTTP_GONE:
		case HTTP_UNPROCESSABLE_ENTITY:
		case HTTP_INTERNAL_ERROR:
			return true;
		default:
			return false;
		}
	}

	/**
	 * Does status code denote a non-error response?
	 *
	 * @param code
	 * @return true if okay, false otherwise
	 */
	protected boolean isOk(final int code) {
		switch (code) {
		case HTTP_OK:
		case HTTP_CREATED:
		case HTTP_ACCEPTED:
			return true;
		default:
			return false;
		}
	}

	/**
	 * Is the response empty?
	 *
	 * @param code
	 * @return true if empty, false otherwise
	 */
	protected boolean isEmpty(final int code) {
		return HTTP_NO_CONTENT == code;
	}

	/**
	 * Parse error from response
	 *
	 * @param response
	 * @return request error
	 * @throws IOException
	 */
	protected RequestError parseError(InputStream response) throws IOException {
		return parseJson(response, RequestError.class);
	}

	/**
	 * Get body from response inputs stream
	 *
	 * @param request
	 * @param stream
	 * @return parsed body
	 * @throws IOException
	 */
	protected Object getBody(GitHubRequest request, InputStream stream)
			throws IOException {
		Type type = request.getType();
		if (type != null)
			return parseJson(stream, type, request.getArrayType());
		else
			return null;
	}

	/**
	 * Create error exception from response and throw it
	 *
	 * @param response
	 * @param code
	 * @param status
	 * @return non-null newly created {@link IOException}
	 */
	protected IOException createException(InputStream response, int code,
			String status) {
		if (isError(code)) {
			final RequestError error;
			try {
				error = parseError(response);
			} catch (IOException e) {
				return e;
			}
			if (error != null)
				return new RequestException(error, code);
		} else
			try {
				response.close();
			} catch (IOException ignored) {
				// Ignored
			}
		String message;
		if (status != null && status.length() > 0)
			message = status + " (" + code + ')'; //$NON-NLS-1$
		else
			message = "Unknown error occurred (" + code + ')'; //$NON-NLS-1$
		return new IOException(message);
	}

	/**
	 * Post to URI
	 *
	 * @param uri
	 * @throws IOException
	 */
	public void post(String uri) throws IOException {
		post(uri, null, null);
	}

	/**
	 * Put to URI
	 *
	 * @param uri
	 * @throws IOException
	 */
	public void put(String uri) throws IOException {
		put(uri, null, null);
	}

	/**
	 * Delete resource at URI. This method will throw an {@link IOException}
	 * when the response status is not a 204 (No Content).
	 *
	 * @param uri
	 * @throws IOException
	 */
	public void delete(String uri) throws IOException {
		delete(uri, null);
	}

	/**
	 * Send parameters to output stream of request
	 *
	 * @param request
	 * @param params
	 * @throws IOException
	 */
	protected void sendParams(HttpURLConnection request, Object params)
			throws IOException {
		request.setDoOutput(true);
		if (params != null) {
			request.setRequestProperty(HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON
					+ "; charset=" + CHARSET_UTF8); //$NON-NLS-1$
			byte[] data = toJson(params).getBytes(CHARSET_UTF8);
			request.setFixedLengthStreamingMode(data.length);
			BufferedOutputStream output = new BufferedOutputStream(
					request.getOutputStream(), bufferSize);
			try {
				output.write(data);
				output.flush();
			} finally {
				try {
					output.close();
				} catch (IOException ignored) {
					// Ignored
				}
			}
		} else {
			request.setFixedLengthStreamingMode(0);
			request.setRequestProperty("Content-Length", "0");
		}
	}

	private <V> V sendJson(final HttpURLConnection request,
			final Object params, final Type type) throws IOException {
		sendParams(request, params);
		final int code = request.getResponseCode();
		updateRateLimits(request);
		if (isOk(code))
			if (type != null)
				return parseJson(getStream(request), type);
			else
				return null;
		if (isEmpty(code))
			return null;
		throw createException(getStream(request), code,
				request.getResponseMessage());
	}

	/**
	 * Create full URI from path
	 *
	 * @param path
	 * @return uri
	 */
	protected String createUri(final String path) {
		return baseUri + configureUri(path);
	}

	/**
	 * Get response stream from GET to URI. It is the responsibility of the
	 * calling method to close the returned stream.
	 *
	 * @param request
	 * @return stream
	 * @throws IOException
	 */
	public InputStream getStream(final GitHubRequest request)
			throws IOException {
		return getResponseStream(createGet(request.generateUri()));
	}

	/**
	 * Get response stream from POST to URI. It is the responsibility of the
	 * calling method to close the returned stream.
	 *
	 * @param uri
	 * @param params
	 * @return stream
	 * @throws IOException
	 */
	public InputStream postStream(final String uri, final Object params)
			throws IOException {
		HttpURLConnection connection = createPost(uri);
		sendParams(connection, params);
		return getResponseStream(connection);
	}

	/**
	 * Get response stream for request
	 *
	 * @param request
	 * @return stream
	 * @throws IOException
	 */
	protected InputStream getResponseStream(final HttpURLConnection request)
			throws IOException {
		InputStream stream = getStream(request);
		int code = request.getResponseCode();
		updateRateLimits(request);
		if (isOk(code))
			return stream;
		else
			throw createException(stream, code, request.getResponseMessage());
	}

	/**
	 * Get stream from request
	 *
	 * @param request
	 * @return stream
	 * @throws IOException
	 */
	protected InputStream getStream(HttpURLConnection request)
			throws IOException {
		if (request.getResponseCode() < HTTP_BAD_REQUEST)
			return request.getInputStream();
		else {
			InputStream stream = request.getErrorStream();
			return stream != null ? stream : request.getInputStream();
		}
	}

	/**
	 * Get response from URI and bind to specified type
	 *
	 * @param request
	 * @return response
	 * @throws IOException
	 */
	public GitHubResponse get(GitHubRequest request) throws IOException {
		HttpURLConnection httpRequest = createGet(request.generateUri());
		String accept = request.getResponseContentType();
		if (accept != null)
			httpRequest.setRequestProperty(HEADER_ACCEPT, accept);
		final int code = httpRequest.getResponseCode();
		updateRateLimits(httpRequest);
		if (isOk(code))
			return new GitHubResponse(httpRequest, getBody(request,
					getStream(httpRequest)));
		if (isEmpty(code))
			return new GitHubResponse(httpRequest, null);
		throw createException(getStream(httpRequest), code,
				httpRequest.getResponseMessage());
	}

	/**
	 * Post data to URI
	 *
	 * @param <V>
	 * @param uri
	 * @param params
	 * @param type
	 * @return response
	 * @throws IOException
	 */
	public <V> V post(final String uri, final Object params, final Type type)
			throws IOException {
		HttpURLConnection request = createPost(uri);
		return sendJson(request, params, type);
	}

	/**
	 * Put data to URI
	 *
	 * @param <V>
	 * @param uri
	 * @param params
	 * @param type
	 * @return response
	 * @throws IOException
	 */
	public <V> V put(final String uri, final Object params, final Type type)
			throws IOException {
		HttpURLConnection request = createPut(uri);
		return sendJson(request, params, type);
	}

	/**
	 * Delete resource at URI. This method will throw an {@link IOException}
	 * when the response status is not a 204 (No Content).
	 *
	 * @param uri
	 * @param params
	 * @throws IOException
	 */
	public void delete(final String uri, final Object params)
			throws IOException {
		HttpURLConnection request = createDelete(uri);
		if (params != null)
			sendParams(request, params);
		final int code = request.getResponseCode();
		updateRateLimits(request);
		if (!isEmpty(code))
			throw new RequestException(parseError(getStream(request)), code);
	}

	/**
	 * Update rate limits present in response headers
	 *
	 * @param request
	 * @return this client
	 */
	protected GitHubClient updateRateLimits(HttpURLConnection request) {
		String limit = request.getHeaderField("X-RateLimit-Limit");
		if (limit != null && limit.length() > 0)
			try {
				requestLimit = Integer.parseInt(limit);
			} catch (NumberFormatException nfe) {
				requestLimit = -1;
			}
		else
			requestLimit = -1;

		String remaining = request.getHeaderField("X-RateLimit-Remaining");
		if (remaining != null && remaining.length() > 0)
			try {
				remainingRequests = Integer.parseInt(remaining);
			} catch (NumberFormatException nfe) {
				remainingRequests = -1;
			}
		else
			remainingRequests = -1;

		return this;
	}

	/**
	 * Get number of requests remaining before rate limiting occurs
	 * <p>
	 * This will be the value of the 'X-RateLimit-Remaining' header from the
	 * last request made
	 *
	 * @return remainingRequests or -1 if not present in the response
	 */
	public int getRemainingRequests() {
		return remainingRequests;
	}

	/**
	 * Get number of requests that {@link #getRemainingRequests()} counts down
	 * from as each request is made
	 * <p>
	 * This will be the value of the 'X-RateLimit-Limit' header from the last
	 * request made
	 *
	 * @return requestLimit or -1 if not present in the response
	 */
	public int getRequestLimit() {
		return requestLimit;
	}
}
