/*
 * 
 * Copyright 2004 Sun Microsystems, Inc. All Rights Reserved.
 * 
 * The contents of this file are subject to the Sun Community Source
 * License v3.0/Jini Technology Specific Attachment v 1.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://wwws.sun.com/software/jini/licensing/SCSL3_JiniTSA1.html.
 * Software distributed under the License is distributed on an "AS IS"
 * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See
 * the License for the specific language governing rights and limitations
 * under the License.
 * 
 * The Reference Code is Jini Technology Core Platform code, v2.0. The
 * Developer of the Reference Code is Sun Microsystems, Inc.
 * 
 * Contributor(s): Sun Microsystems, Inc.
 * 
 * The contents of this file comply with the Jini Technology Core
 * Platform Compatibility Kit, v2.0A.
 * 
 * Tester(s): Sun Microsystems, Inc.
 * 
 * Test Platform(s):
 * Java 2 SDK, Standard Edition, Version 1.4.2_03 for Solaris(TM)SPARC/x86
 * Java 2 SDK, Standard Edition, Version 1.4.2_03 for Linux (Intel x86)
 * Java 2 SDK, Standard Edition, Version 1.4.2_03 for Windows (Intel Platform)
 * 
 */

package com.sun.jini.jeri.internal.http;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ConnectException;
import java.net.HttpURLConnection;
import java.net.Socket;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.Collection;
import net.jini.core.constraint.InvocationConstraints;
import net.jini.jeri.OutboundRequest;
import net.jini.io.context.AcknowledgmentSource;

/**
 * Class representing a client-side HTTP connection used to send HTTP requests.
 *
 * @author Sun Microsystems, Inc.
 * 
 */
public class HttpClientConnection implements TimedConnection {
    
    private static final int HTTP_MAJOR = 1;
    private static final int HTTP_MINOR = 1;
    
    private static final String clientString = (String)
	AccessController.doPrivileged(new PrivilegedAction() {
	    public Object run() {
		return "Java/" + System.getProperty("java.version", "???") +
		       " " + HttpClientConnection.class.getName();
	    }
	});

    /* modes */
    private static final int DIRECT   = 0;
    private static final int PROXIED  = 1;
    private static final int TUNNELED = 2;
    
    /* states */
    private static final int IDLE     = 0;
    private static final int BUSY     = 1;
    private static final int CLOSED   = 2;

    private final int mode;
    private final Object stateLock = new Object();
    private int state = IDLE;

    private final HttpClientManager manager;
    private ServerInfo targetInfo;
    private ServerInfo proxyInfo;
    private String[] acks;

    private Socket sock;
    private OutputStream out;
    private InputStream in;

    /**
     * Creates HttpClientConnection which sends requests directly to given
     * host/port through a socket obtained from the given socket factory.
     * The createSocket method of the given socket factory may be called
     * more than once in cases where connection establishment involves multiple
     * HTTP message exchanges.
     */
    public HttpClientConnection(String host,
				int port,
				HttpClientSocketFactory factory,
				HttpClientManager manager)
	throws IOException
    {
	this.manager = manager;
	mode = DIRECT;
	targetInfo = manager.getServerInfo(host, port);
	setupConnection(factory);
    }
    
    /**
     * Creates HttpClientConnection which sends requests to given target
     * host/port through the indicated HTTP proxy over a socket provided by the
     * specified socket factory.  If tunnel is true, requests are tunneled
     * through the proxy over an additional layered socket (also provided by
     * the socket factory).  The createSocket and createTunnelSocket methods of
     * the given socket factory may be called more than once in cases where
     * connection establishment involves multiple HTTP message exchanges.
     */
    public HttpClientConnection(String targetHost,
				int targetPort,
				String proxyHost,
				int proxyPort,
				boolean tunnel,
				HttpClientSocketFactory factory,
				HttpClientManager manager)
	throws IOException
    {
	this.manager = manager;
	mode = tunnel ? TUNNELED : PROXIED;
	targetInfo = manager.getServerInfo(targetHost, targetPort);
	proxyInfo = manager.getServerInfo(proxyHost, proxyPort);
	setupConnection(factory);
    }
    
    /**
     * Pings target.  Returns true if ping succeeded, false if it fails
     * "cleanly" (i.e., if a valid HTTP response indicating request failure is
     * received).
     */
    public boolean ping() throws IOException {
	markBusy();
	fetchServerInfo();
	try {
	    return ping(false);
	} finally {
	    markIdle();
	}
    }
    
    /**
     * Initiates new request to connection target.  Throws an IOException if
     * the connection is currently busy.
     */
    public OutboundRequest newRequest() throws IOException {
	OutboundRequest req = null;
	markBusy();
	fetchServerInfo();
	try {
	    req = new OutboundRequestImpl();
	    return req;
	} finally {
	    if (req == null) {
		markIdle();
	    }
	}
    }
    
    /**
     * Upcall indicating that connection has become idle.  Subclasses may
     * override this method to perform an appropriate action, such as
     * scheduling an idle timeout.
     */
    protected void idle() {
    }
    
    /**
     * Returns socket over which requests are sent.
     */
    public Socket getSocket() {
	return sock;
    }
    
    /**
     * Attempts to shut down connection, returning true if connection is
     * closed.  If force is true, connection is always shut down; if force is
     * false, connection is only shut down if idle.
     */
    public boolean shutdown(boolean force) {
	synchronized (stateLock) {
	    if (state == CLOSED) {
		return true;
	    }
	    if (!force && state == BUSY) {
		return false;
	    }
	    state = CLOSED;
	}
	disconnect();
	return true;
    }
    
    /**
     * Fetches latest server/proxy HTTP information from cache.
     */
    private void fetchServerInfo() {
	ServerInfo sinfo = 
	    manager.getServerInfo(targetInfo.host, targetInfo.port);
	if (sinfo.timestamp > targetInfo.timestamp) {
	    targetInfo = sinfo;
	}
	if (mode != DIRECT) {
	    sinfo = manager.getServerInfo(proxyInfo.host, proxyInfo.port);
	    if (sinfo.timestamp > proxyInfo.timestamp) {
		proxyInfo = sinfo;
	    }
	}
    }
    
    /**
     * Flushes current copy of server/proxy HTTP information to cache.
     */
    private void flushServerInfo() {
	manager.cacheServerInfo(targetInfo);
	if (mode != DIRECT) {
	    manager.cacheServerInfo(proxyInfo);
	}
    }
    
    /**
     * Marks connection busy.  Throws IOException if connection closed.
     */
    private void markBusy() throws IOException {
	synchronized (stateLock) {
	    if (state == BUSY) {
		throw new IOException("connection busy");
	    } else if (state == CLOSED) {
		throw new IOException("connection closed");
	    }
	    state = BUSY;
	}
    }
    
    /**
     * Marks connection idle.  Does nothing if connection closed.
     */
    private void markIdle() {
	synchronized (stateLock) {
	    if (state == CLOSED) {
		return;
	    }
	    state = IDLE;
	}
	idle();
    }

    /**
     * Establishes connection using sockets from the given socket factory.
     * Throws IOException if connection setup fails.
     */
    private void setupConnection(HttpClientSocketFactory factory)
	throws IOException
    {
	boolean ok = false;
	try {
	    /*
	     * 4 cycles required in worst-case (proxied) scenario:
	     * i = 0: send OPTIONS request to proxy
	     * i = 1: send ping, fails with 407 (proxy auth required)
	     * i = 2: send ping, fails with 401 (unauthorized)
	     * i = 3: return
	     */
	    for (int i = 0; i < 4; i++) {
		if (sock == null) {
		    connect(factory);
		}
		if (mode == PROXIED && 
		    proxyInfo.timestamp == ServerInfo.NO_TIMESTAMP)
		{
		    requestProxyOptions();
		} else if (targetInfo.timestamp == ServerInfo.NO_TIMESTAMP) {
		    ping(true);
		} else {
		    ok = true;
		    return;
		}
	    }
	} finally {
	    if (!ok) {
		disconnect();
	    }
	}
	throw new ConnectException("failed to establish HTTP connection");
    }
    
    /**
     * Opens underlying connection.  If tunneling through an HTTP proxy,
     * attempts CONNECT request.
     */
    private void connect(HttpClientSocketFactory factory) throws IOException {
	disconnect();
	for (int i = 0; i < 2; i++) {
	    if (sock == null) {
		ServerInfo sinfo = (mode == DIRECT) ? targetInfo : proxyInfo;
		sock = factory.createSocket(sinfo.host, sinfo.port);
		out = new BufferedOutputStream(sock.getOutputStream());
		in = new BufferedInputStream(sock.getInputStream());
	    }
	    if (mode != TUNNELED) {
		return;
	    }
	    if (requestProxyConnect()) {
		sock = factory.createTunnelSocket(sock);
		out = new BufferedOutputStream(sock.getOutputStream());
		in = new BufferedInputStream(sock.getInputStream());
		return;
	    }
	}
	throw new ConnectException("failed to establish proxy tunnel");
    }
    
    /**
     * Closes and releases reference to underlying socket.
     */
    private void disconnect() {
	if (sock != null) {
	    try { sock.close(); } catch (IOException ex) {}
	    sock = null;
	    out = null;
	    in = null;
	}
    }

    /**
     * Pings target.  Returns true if succeeded, false if failed "cleanly".
     */
    private boolean ping(boolean setup) throws IOException {
	StartLine outLine = createPostLine();
	Header outHeader = createPostHeader(outLine);
	outHeader.setField("RMI-Request-Type", "ping");
	MessageWriter writer = new MessageWriter(out, false);

	writer.writeStartLine(outLine);
	writer.writeHeader(outHeader);
	writer.writeTrailer(null);

	MessageReader reader;
	StartLine inLine;
	Header inHeader;
	do {
	    reader = new MessageReader(in, false);
	    inLine = reader.readStartLine();
	    inHeader = reader.readHeader();
	    inHeader.merge(reader.readTrailer());
	} while (inLine.status / 100 == 1);

	analyzePostResponse(inLine, inHeader);
	if (!supportsPersist(inLine, inHeader)) {
	    if (setup) {
		disconnect();
	    } else {
		shutdown(true);
	    }
	}
	return (inLine.status / 100) == 2;
    }
    
    /**
     * Sends OPTIONS request to proxy.  Returns true if OPTIONS succeeded,
     * false otherwise.
     */
    private boolean requestProxyOptions() throws IOException {
	MessageWriter writer = new MessageWriter(out, false);
	writer.writeStartLine(
	    new StartLine(HTTP_MAJOR, HTTP_MINOR, "OPTIONS", "*"));
	writer.writeHeader(createProxyHeader());
	writer.writeTrailer(null);

	MessageReader reader;
	StartLine inLine;
	Header inHeader;
	do {
	    reader = new MessageReader(in, false);
	    inLine = reader.readStartLine();
	    inHeader = reader.readHeader();
	    inHeader.merge(reader.readTrailer());
	} while (inLine.status / 100 == 1);

	analyzeProxyResponse(inLine, inHeader);
	if (!supportsPersist(inLine, inHeader)) {
	    disconnect();
	}
	return (inLine.status / 100) == 2;
    }
    
    /**
     * Sends CONNECT request to proxy.  Returns true if CONNECT succeeded,
     * false otherwise.
     */
    private boolean requestProxyConnect() throws IOException {
	StartLine outLine = new StartLine(
	    HTTP_MAJOR, HTTP_MINOR, "CONNECT",
	    targetInfo.host + ":" + targetInfo.port);

	// REMIND: eliminate hardcoded protocol string
	Header outHeader = createProxyHeader();
	String auth = 
	    proxyInfo.getAuthString("http", outLine.method, outLine.uri);
	if (auth != null) {
	    outHeader.setField("Proxy-Authorization", auth);
	}

	MessageWriter writer = new MessageWriter(out, false);
	writer.writeStartLine(outLine);
	writer.writeHeader(outHeader);
	writer.writeTrailer(null);
	
	MessageReader reader;
	StartLine inLine;
	Header inHeader;
	do {
	    reader = new MessageReader(in, true);
	    inLine = reader.readStartLine();
	    inHeader = reader.readHeader();
	    inHeader.merge(reader.readTrailer());
	} while (inLine.status / 100 == 1);

	analyzeProxyResponse(inLine, inHeader);
	if ((inLine.status / 100) == 2) {
	    return true;
	}
	if (!supportsPersist(inLine, inHeader)) {
	    disconnect();
	}
	return false;
    }
    
    /**
     * Creates start line for outbound HTTP POST message.
     */
    private StartLine createPostLine() {
	String uri = (mode == PROXIED) ?
	    "http://" + targetInfo.host + ":" + targetInfo.port + "/" : "/";
	return new StartLine(HTTP_MAJOR, HTTP_MINOR, "POST", uri);
    }
    
    /**
     * Creates base header containing fields common to all HTTP messages sent
     * by this connection.
     */
    private Header createBaseHeader() {
	Header header = new Header();
	long now = System.currentTimeMillis();
	header.setField("Date", Header.getDateString(now));
	header.setField("User-Agent", clientString);
	return header;
    }

    /**
     * Creates header for use in OPTIONS and CONNECT messages sent to proxies.
     */
    private Header createProxyHeader() {
	Header header = createBaseHeader();
	header.setField("Host", proxyInfo.host + ":" + proxyInfo.port);
	return header;
    }
    
    /**
     * Creates header for outbound HTTP POST message with given start line.
     */
    private Header createPostHeader(StartLine sline) {
	Header header = createBaseHeader();
	header.setField("Host", targetInfo.host + ":" + targetInfo.port);
	header.setField("Connection", "TE");
	header.setField("TE", "trailers");
	
	// REMIND: eliminate hardcoded protocol string
	String auth = 
	    targetInfo.getAuthString("http", sline.method, sline.uri);
	if (auth != null) {
	    header.setField("Authorization", auth);
	}
	if (mode == PROXIED) {
	    auth = proxyInfo.getAuthString("http", sline.method, sline.uri);
	    if (auth != null) {
		header.setField("Proxy-Authorization", auth);
	    }
	}

	acks = manager.getUnsentAcks(targetInfo.host, targetInfo.port);
	if (acks.length > 0) {
	    String ackList = acks[0];
	    for (int i = 1; i < acks.length; i++) {
		ackList += ", " + acks[i];
	    }
	    header.setField("RMI-Response-Ack", ackList);
	}
	return header;
    }
    
    /**
     * Analyzes POST response message start line and header, updating cached
     * target/proxy server information if necessary.
     */
    private void analyzePostResponse(StartLine inLine, Header inHeader) {
	String str;
	long now = System.currentTimeMillis();
	
	if ((str = inHeader.getField("WWW-Authenticate")) != null) {
	    try {
		targetInfo.setAuthInfo(str);
	    } catch (HttpParseException ex) {
	    }
	    targetInfo.timestamp = now;
	} else if ((str = inHeader.getField("Authentication-Info")) != null) {
	    try {
		targetInfo.updateAuthInfo(str);
	    } catch (HttpParseException ex) {
	    }
	    targetInfo.timestamp = now;
	}
	
	if (mode != DIRECT) {
	    if ((str = inHeader.getField("Proxy-Authenticate")) != null) {
		try {
		    proxyInfo.setAuthInfo(str);
		} catch (HttpParseException ex) {
		}
		proxyInfo.timestamp = now;
	    } else if ((str = inHeader.getField(
			    "Proxy-Authentication-Info")) != null)
	    {
		try {
		    proxyInfo.updateAuthInfo(str);
		} catch (HttpParseException ex) {
		}
		proxyInfo.timestamp = now;
	    }
	}

	if (mode != PROXIED) {
	    targetInfo.major = inLine.major;
	    targetInfo.minor = inLine.minor;
	    targetInfo.timestamp = now;
	} else {
	    /* Return message was sent by proxy; however, since some proxies
	     * incorrectly relay the target server's version numbers instead of
	     * their own, we can only rely on version numbers which could not
	     * have been sent from target server.
	     */
	    if (inLine.status == HttpURLConnection.HTTP_PROXY_AUTH) {
		proxyInfo.major = inLine.major;
		proxyInfo.minor = inLine.minor;
	    }
	    proxyInfo.timestamp = now;
	}
	
	if ((inLine.status / 100) == 2) {
	    manager.clearUnsentAcks(targetInfo.host, targetInfo.port, acks);
	    targetInfo.timestamp = now;
	}

	flushServerInfo();
    }
    
    /**
     * Analyzes CONNECT or OPTIONS response message start line and header sent
     * by proxy, updating proxy server information if necessary.
     */
    private void analyzeProxyResponse(StartLine inLine, Header inHeader) {
	proxyInfo.major = inLine.major;
	proxyInfo.minor = inLine.minor;
	proxyInfo.timestamp = System.currentTimeMillis();
	
	String str;
	if ((str = inHeader.getField("Proxy-Authenticate")) != null) {
	    try {
		proxyInfo.setAuthInfo(str);
	    } catch (HttpParseException ex) {
	    }
	} else if ((str = inHeader.getField(
			"Proxy-Authentication-Info")) != null)
	{
	    try {
		proxyInfo.updateAuthInfo(str);
	    } catch (HttpParseException ex) {
	    }
	}

	flushServerInfo();
    }

    /**
     * Returns true if requests sent over this connection should chunk output.
     */
    private boolean supportsChunking() {
	ServerInfo si = (mode == PROXIED) ? proxyInfo : targetInfo;
	return StartLine.compareVersions(si.major, si.minor, 1, 1) >= 0;
    }
    
    /**
     * Returns true if the given response line and header indicate that the
     * connection can be persisted.
     */
    private static boolean supportsPersist(StartLine sline, Header header) {
	if (header.containsValue("Connection", "close", true)) {
	    return false;
	} else if (header.containsValue("Connection", "Keep-Alive", true)) {
	    return true;
	} else {
	    int c = StartLine.compareVersions(sline.major, sline.minor, 1, 1);
	    return c >= 0;
	}
    }

    /**
     * HTTP-based implementation of OutboundRequest abstraction.
     */
    private class OutboundRequestImpl 
	extends Request implements OutboundRequest
    {
	private final MessageWriter writer;
	private MessageReader reader;
	private StartLine inLine;
	private Header inHeader;
	private boolean persist = false;
	
	OutboundRequestImpl() throws IOException {
	    StartLine outLine = createPostLine();
	    Header outHeader = createPostHeader(outLine);
	    outHeader.setField("RMI-Request-Type", "standard");

	    writer = new MessageWriter(out, supportsChunking());
	    writer.writeStartLine(outLine);
	    writer.writeHeader(outHeader);
	    writer.flush();
	}

	public void populateContext(Collection context) {
	    if (context == null) {
		throw new NullPointerException();
	    }
	}

	public InvocationConstraints getUnfulfilledConstraints() {
	    /*
	     * NYI: With no request-specific hook, we depend on
	     * OutboundRequest wrapping for this method.
	     */
	    throw new AssertionError();
	}

	public OutputStream getRequestOutputStream() {
	    return getOutputStream();
	}
	
	public InputStream getResponseInputStream() {
	    return getInputStream();
	}

	void startOutput() throws IOException {
	    // start line, header already written
	}

	void write(byte[] b, int off, int len) throws IOException {
	    writer.writeContent(b, off, len);
	}

	void endOutput() throws IOException {
	    writer.writeTrailer(null);
	}

	boolean startInput() throws IOException {
	    for (;;) {
		reader = new MessageReader(in, false);
		inLine = reader.readStartLine();
		inHeader = reader.readHeader();
		if (inLine.status / 100 != 1) {
		    return inLine.status / 100 == 2;
		}
		reader.readTrailer();
	    }
	}

	int read(byte[] b, int off, int len) throws IOException {
	    return reader.readContent(b, off, len);
	}

	int available() throws IOException {
	    return reader.availableContent();
	}

	void endInput() throws IOException {
	    inHeader.merge(reader.readTrailer());
	    analyzePostResponse(inLine, inHeader);
	    persist = supportsPersist(inLine, inHeader);
	}

	void addAckListener(AcknowledgmentSource.Listener listener) {
	    throw new UnsupportedOperationException();
	}

	void done(boolean corrupt) {
	    if (corrupt || !persist) {
		shutdown(true);
	    } else {
		markIdle();
	    }
	}
    }
}
