/* 
 * E-XML Library:  For XML, XML-RPC, HTTP, and related.
 * Copyright (C) 2002-2008  Elias Ross
 * 
 * genman@noderunner.net
 * http://noderunner.net/~genman
 * 
 * 1025 NE 73RD ST
 * SEATTLE WA 98115
 * USA
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 * 
 * $Id$
 */

package net.noderunner.http;

import java.io.IOException;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.Socket;
import java.net.URL;

import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;

/**
 * A <code>HttpClient</code> implementation that retries contacting a remote URL and
 * allows for persistant connections.	For each retry, this client waits an
 * additional second before attempting a new connection.	Note that if a
 * failure occurs reading the server response, retries may or may not be made
 * only for non-<code>POST</code> and <code>PUT</code> methods.	Refer to
 * {@link #readResponse readResponse} on this is handled.
 * <p>
 * Also, this client handles most redirects, on either the same host or
 * different host.	By default, note that redirects will only be followed for
 * non-<code>POST</code> and <code>PUT</code> methods.	
 * Refer to {@link #setFollowRedirects setFollowRedirects} on this.
 * </p>
 * <p>
 * HTTPS (HTTP over SSL) relies on the <code>javax.net.ssl</code> library, and
 * may require <code>com.sun.net.ssl.internal.ssl.Provider</code> to be
 * installed using the <code>java.security.Security.addProvider</code> method.
 * </p>
 * <p>
 * Many of the <code>protected</code> methods may be overriden
 * to supply specific functionality.	For example, by default
 * the underlying HttpClient is the {@link BasicHttpClient},
 * this can be easily changed.
 * </p>
 */
public class RetryHttpClient
	implements HttpClient
{

	/**
	 * Default number of tries.
	 */
	public static final int DEFAULT_MAX_TRIES = 3;

	/**
	 * Lazy-constructed.
	 */
	private static SocketFactory sslSocketFactory;

	private URL url;
	private int maxTries;
	private boolean open;
	private HttpClient client;
	private ClientRequest request;
	private boolean followRedirects;
	private boolean skipContinues;

	/**
	 * Constructs a <code>RetryHttpClient</code> that retries a number of times.
	 * @param maxTries must be greater than zero
	 */
	public RetryHttpClient(URL url, int maxTries) {
		if (url == null)
			throw new IllegalArgumentException("Null URL");
		if (maxTries < 1)
			throw new IllegalArgumentException("Invalid number of retries");
		this.url = url;
		this.maxTries = maxTries;
		this.followRedirects = true;
		this.skipContinues = true;
	}

	/**
	 * Constructs a <code>RetryHttpClient</code> that retries 3 times.
	 */
	public RetryHttpClient(URL url) {
		this(url, DEFAULT_MAX_TRIES);
	}

	/**
	 * Callback that indicates the connection failed and will be retried.	The
	 * default implementation simply closes the connection.
	 * @param failures number of failures (counting from 0)
	 */
	protected void retrySendRequest(IOException e, int failures) {
		try {
			close();
		} catch (IOException ex2) {
		}
	}

	/**
	 * Returns a newly constructed socket for a given URL.
	 * By default, the HTTP port to use will be 80.
	 */
	protected Socket makeSocket(URL url)
		throws IOException
	{
		int port = url.getPort();
		if (port == -1)
			port = 80;
		Socket s = new Socket(url.getHost(), port);
		s.setSoTimeout(30 * 1000);
		return s;
	}

	/**
	 * Returns a newly constructed SSL socket for a given URL.
	 * By default, the HTTPS port to use will be 443.
	 */
	protected Socket makeSSLSocket(URL url)
		throws IOException
	{
		int port = url.getPort();
		if (port == -1)
			port = 443;
		if (sslSocketFactory == null)
			sslSocketFactory = SSLSocketFactory.getDefault();
		return sslSocketFactory.createSocket(url.getHost(), 443);
	}

	/**
	 * Sets the socket options to use.
	 * By default, sets the read timeout to 30 seconds.
	 */
	protected void setSocketOptions(Socket socket)
		throws IOException
	{
		socket.setSoTimeout(30 * 1000);
	}

	/**
	 * Returns a newly constructed HTTP client for a given URL.
	 * Calls {@link #makeSocket} to construct a network socket
	 * for HTTP, {@link #makeSSLSocket} for HTTPS.
	 * Calls {@link #setSocketOptions} to set socket options.
	 */
	protected HttpClient makeHttpClient(URL url)
		throws IOException
	{
		Socket socket;
		String p = url.getProtocol();
		if ("http".equals(p))
			socket = makeSocket(url);
		else if ("https".equals(p))
			socket = makeSSLSocket(url);
		else throw new IOException("Unknown protocol: " + url);

		setSocketOptions(socket);

		return new BasicHttpClient(socket);
	}

	/**
	 * Attempts to send an HTTP request, and may retry to send a certain
	 * number of times.
	 * Calls {@link #makeHttpClient} to construct a new client.
	 */
	public void writeRequest(ClientRequest request)
		throws IOException
	{
		this.request = request;
		IOException lastException = null;
		int tries = 0;
		do {
			if (!open)
				client = makeHttpClient(url);
			try {
				client.writeRequest(request);
				open = true;
				return;
			} catch (IOException e) {
				lastException = e;
				retrySendRequest(e, tries);
			}
		} while (tries++ < maxTries);
		throw lastException;
	}

	/**
	 * Returns a stream for writing data to, if data is to be sent to the
	 * server.
	 */
	public OutputStream getOutputStream()
	{
		if (!open)
			throw new IllegalHttpStateException("Client not open");
		return client.getOutputStream();
	}

	private boolean differentHost(URL newUrl) {
		return (!newUrl.getHost().equals(url.getHost()) ||
			newUrl.getPort() != url.getPort());
	}

	/**
	 * Kind of convoluted way of making a new request.
	 */
	private void setupClientRequest(URL newUrl) {
		MessageHeaders headers = request.getHeaders();
		if (differentHost(newUrl)) {
			MessageHeader hh = MessageHeader.makeHostHeader(newUrl);
			headers.add(hh);
		}
		RequestLine rl = new RequestLine(request.getRequestLine().getMethod(), newUrl.getFile()); 
		this.request = new ClientRequest(rl,
				headers, request.getDataPoster());
		this.url = newUrl;
	}

	/**
	 * Returns true if the response is a 100 continue code.
	 * Ignores 100 to 199 status codes.
	 */
	private boolean doingContinue(ClientResponse r) {
		if (!skipContinues)
			return false;
		return r.isContinue();
	}

	/**
	 * Returns true, if the response is a redirect and we should follow.
	 * Follows 301, 302, and 307 status codes.
	 * If we're redirecting to a different host, the old request is closed.
	 * Doesn't really follow the official redirection guidelines in the
	 * <a href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html}">RFC</a>.
	 */
	private boolean doingRedirect(ClientResponse r)
		throws IOException
	{
		if (!followRedirects)
			return false;
		int code = r.getStatusLine().getStatusCode();
		if (code != 301 && code != 302 && code != 303 && code != 307)
			return false;
		String location = r.getHeaders().getFieldContent(MessageHeader.FN_LOCATION);
		if (location == null)
			throw new HttpException("No location header in HTTP redirect");
		location = location.trim();
		URL newUrl;
		try {
			// build new URL from the old
			newUrl = new URL(url, location);
		} catch (MalformedURLException e) {
			throw new HttpException("Location header has invalid URL: " + location);
		}
		if (differentHost(newUrl))
			close();
		else
			r.readFully();
		setupClientRequest(newUrl);
		return true;
	}

	private boolean closeConnection(ClientResponse r) {
		return r.getHeaders().contains(MessageHeader.MH_CONNECTION_CLOSE);
	}

	/**
	 * Reads the response data from the HTTP server.
	 * <p>
	 * If the response data cannot be read, then the connection will be
	 * closed, and {@link #writeRequest writeRequest} will be called
	 * up to the maximum of retries as previously set.
	 * The original request will not retried if the request method was
	 * <code>PUT</code> or <code>POST</code> and {@link
	 * ClientRequest#getDataPoster} returns <code>null</code>.
	 * </p>
	 * <p>
	 * If a redirection response was read, it is followed only if redirection
	 * is enabled.
	 * </p>
	 */
	public ClientResponse readResponse()
		throws IOException
	{
		if (!open)
			throw new IllegalHttpStateException("Client not open");

		int tries = 0;
		IOException lastException = null;
		do {
			if (!open)
				writeRequest(request);
			try {
				ClientResponse r = client.readResponse();
				this.open = !closeConnection(r);

				// Check for continue
				if (doingContinue(r)) {
					continue;
				}

				// Can't (always) redirect if we are posting ...
				if (request.getDataPoster() == null) {
					Method m = request.getRequestLine().getMethod();
					if (m == Method.POST || m == Method.PUT)
						return r;
				}

				// Check redirect
				if (!doingRedirect(r))
					return r;
				else
					writeRequest(request);

			} catch (IOException e) {
				lastException = e;
				retrySendRequest(e, tries);
			}
		} while (tries++ < maxTries);
		if (lastException == null) // must be redirect failed
			throw new HttpException("Redirected too many times: " + maxTries);
		throw lastException;
	}

	/**
	 * Closes the wrapped {@link HttpClient}.
	 * The underlying client may not be re-used.
	 */
	public void close()
		throws IOException
	{
		if (open)
			client.close();
		open = false;
	}

	/**
	 * Sets whether HTTP redirects should be followed.
	 * These are 300-level response codes.	By default, redirects are followed.
	 * The max number of redirects followed is equal to the number of
	 * retries, as was set in the constructor.
	 */
	public void setFollowRedirects(boolean followRedirects)
	{
		this.followRedirects = followRedirects;
	}

	/**
	 * Sets whether HTTP continue responses should be skipped.
	 * These are 100-level response codes.	By default, continue responses are ignored.
	 * There is no limit to the number of these that will be read.
	 */
	public void setSkipContinues(boolean skipContinues)
	{
		this.skipContinues = skipContinues;
	}

	/**
	 * Returns debug information.
	 */
	public String toString() {
		return super.toString() + " maxTries=" + maxTries + 
			" client=[" + client + "] url=" + url + " open=" + open;
	}

}
