/*
 * Copyright 2015 Red Hat, Inc. and/or its affiliates.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * 
 *      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.eclipse.webdav.http.client;

import java.io.*;
import java.net.*;
import java.util.Date;
import java.util.Vector;
import org.eclipse.webdav.client.Policy;
import org.eclipse.webdav.internal.kernel.utils.Assert;

/**
 * A connection to an HTTP/1.0 or HTTP/1.1 compatable server.
 * <p>
 * <b>Note:</b> This class/interface is part of an interim API that is still under 
 * development and expected to change significantly before reaching stability. 
 * It is being made available at this early stage to solicit feedback from pioneering 
 * adopters on the understanding that any code that uses this API will almost 
 * certainly be broken (repeatedly) as the API evolves.
 * </p>
 */
public class HttpConnection implements IStatusCodes {
    // The HTTP version to use (1.0 or 1.1).
    private double httpVersion = 1.1;

    // The request method.
    private String method = "GET"; //$NON-NLS-1$

    // The URL of the requested resource.
    private URL resourceUrl = null;

    // The URL of the proxy server.
    private URL proxyServerUrl = null;

    // The request and response headers.
    protected Header requestHeader = new Header();
    protected Header responseHeader = new Header();
    protected Header internalHeader = new Header();

    // The status code and status message of the response.
    private int statusCode;
    private String statusMessage;

    // The socket factory, socket, and socket settings.
    private ISocketFactory socketFactory = null;
    private Socket socket = null;
    private int receiveBufferSize = 0;
    private int sendBufferSize = 0;
    private int soLinger = -1;
    private int soTimeout = 0;
    private boolean tcpNoDelay = false;

    // The actual socket streams.
    protected InputStream socketIn = null;
    protected OutputStream socketOut = null;

    // The user's socket streams.
    private InputStream is = null;
    private OutputStream os = null;

    // A boolean indicating whether the connection should be closed after
    // the current request.
    private boolean closeConnection;

    // A boolean indicating whether the client wishes the connection to
    // remain open after the current request.
    private boolean persistent = true;

    // A boolean indicating whether the client wishes the request's
    // content to be sent chunked encoded.
    private boolean sendChunked = true;

    // A boolean indicating whether this connection is currently
    // connected to a server.
    private boolean connected = false;

    // A boolean indicating whether the current request has been sent.
    private boolean sentRequest = false;

    // This connection's timestamp.
    private Date timestamp = null;

    /**
     * A request or response header.
     */
    public class Header {
        // This header's field names
        private Vector fieldNames = new Vector(5);

        // This header's field values
        private Vector fieldValues = new Vector(5);

        /**
         * Adds the specified header field to this header.
         *
         * @param fieldName the new header field's name
         * @param fieldValue the new header field's value
         */
        public void addField(String fieldName, String fieldValue) {
            Assert.isNotNull(fieldName);
            Assert.isNotNull(fieldValue);
            fieldNames.addElement(fieldName);
            fieldValues.addElement(fieldValue);
        }

        /**
         * Removes all header fields from this header.
         */
        public void clear() {
            fieldNames.removeAllElements();
            fieldValues.removeAllElements();
        }

        /**
         * Returns the field value of the header field at the given
         * position, or <code>null</code> if there is no such postition.
         *
         * @param position the position of a header field
         * @return the field value of the header field at the given
         * position
         */
        public String getFieldValue(int position) {
            if (position < 0 || position >= fieldValues.size())
                return null;
            return (String) fieldValues.elementAt(position);
        }

        /**
         * Returns the field value of the header field with the given
         * field name, or <code>null</code> if there is no such field
         * name.
         *
         * @param fieldName the name of a header field
         * @return the field value of the header field with the given
         * field name
         */
        public String getFieldValue(String fieldName) {
            Assert.isNotNull(fieldName);
            return getFieldValue(fieldNames.indexOf(fieldName));
        }

        /**
         * Returns the field name of the header field at the given
         * position, or <code>null</code> if there is no such position.
         *
         * @param position the position of a header field in this header
         * @return the field name of the header field at the given
         * position
         */
        public String getFieldName(int position) {
            if (position < 0 || position >= fieldNames.size())
                return null;
            return (String) fieldNames.elementAt(position);
        }

        /**
         * Returns the number of header fields in this header.
         *
         * @return the number of header fields in this header
         */
        public int size() {
            return fieldNames.size();
        }
    }

    /**
     * A limited input stream reads up to a given number of bytes from
     * this connection's underlying socket input stream.
     */
    private class LimitedInputStream extends InputStream {
        /**
         * The number of bytes remaining in this stream.
         */
        private int bytesRemaining;

        /**
         * Creates a new limited input stream that reads up to
         * <code>length</code> bytes of data from this connection's
         * underlying socket input stream.
         *
         * @param length the length of this input stream
         */
        public LimitedInputStream(int length) {
            Assert.isTrue(length >= 0);
            bytesRemaining = length;
        }

        /**
         * @see InputStream#available()
         */
        public int available() throws IOException {
            return Math.min(socketIn.available(), bytesRemaining);
        }

        /**
         * @see InputStream#close()
         */
        public void close() throws IOException {
            while (skip(4096) > 0);
        }

        /**
         * @see InputStream#read()
         */
        public int read() throws IOException {
            if (bytesRemaining <= 0)
                return -1;
            --bytesRemaining;
            return socketIn.read();
        }

        /**
         * @see InputStream#read(byte[], int, int)
         */
        public int read(byte[] buf, int offset, int length) throws IOException {
            if (bytesRemaining <= 0)
                return -1;
            int result = socketIn.read(buf, offset, Math.min(length, bytesRemaining));
            if (result > 0)
                bytesRemaining -= result;
            return result;
        }

        /**
         * @see InputStream#skip(int)
         */
        public long skip(int amount) throws IOException {
            if (bytesRemaining <= 0)
                return -1;
            long result = socketIn.skip(Math.min(amount, bytesRemaining));
            if (result > 0)
                bytesRemaining -= result;
            return result;
        }
    }

    /**
     * A chunked input stream reads bytes, that have been chunked
     * transfer encoded, from this connection's underlying socket input
     * stream.
     */
    private class ChunkedInputStream extends InputStream {

        /**
         * The number of bytes remaining in this stream's current chunk.
         */
        private int bytesRemaining = -1;

        /**
         * A boolean indicating whether the end of this stream has been
         * reached.
         */
        private boolean atEnd = false;

        /**
         * @see InputStream#available()
         */
        public int available() throws IOException {
            return Math.min(socketIn.available(), bytesRemaining);
        }

        /**
         * @see InputStream#close()
         */
        public void close() throws IOException {
            while (skip(4096) > 0);
        }

        /**
         * @see InputStream#read()
         */
        public int read() throws IOException {
            if (!atEnd && bytesRemaining <= 0)
                readChunkSize();
            if (atEnd)
                return -1;
            --bytesRemaining;
            return socketIn.read();
        }

        /**
         * @see InputStream#read(byte[], int, int)
         */
        public int read(byte[] buf, int offset, int length) throws IOException {
            if (!atEnd && bytesRemaining <= 0)
                readChunkSize();
            if (atEnd)
                return -1;
            int result = socketIn.read(buf, offset, Math.min(length, bytesRemaining));
            if (result > 0)
                bytesRemaining -= result;
            return result;
        }

        /**
         * Read the size of the next chunk of data.
         */
        private void readChunkSize() throws IOException {
            if (bytesRemaining == 0)
                readln();
            String size = readln();
            int index = size.indexOf(";"); //$NON-NLS-1$
            if (index >= 0)
                size = size.substring(0, index);
            try {
                bytesRemaining = Integer.parseInt(size.trim(), 16);
            } catch (NumberFormatException exception) {
                throw new IOException(exception.getMessage());
            }
            if (bytesRemaining == 0) {
                atEnd = true;
                readHeader(responseHeader);
            }
        }

        /**
         * @see InputStream#skip(int)
         */
        public long skip(int amount) throws IOException {
            if (!atEnd && bytesRemaining <= 0)
                readChunkSize();
            if (atEnd)
                return -1;
            long result = socketIn.skip(Math.min(amount, bytesRemaining));
            if (result > 0)
                bytesRemaining -= result;
            return result;
        }
    }

    /**
     * A limited output stream writes up to a given number of bytes to
     * this connection's underlying socket input stream.
     */
    private class LimitedOutputStream extends OutputStream {
        // The maximum number of bytes that can be written to this
        // stream.
        private int limit;

        // A boolean indicating whether this stream is closed.
        private boolean closed = false;

        /**
         * Creates a new limited output stream that writes up to the
         * given limit of bytes to this connection's underlying socket
         * output stream.
         *
         * @param limit the maximum number of bytes that can be written
         * to this stream
         */
        public LimitedOutputStream(int limit) {
            Assert.isTrue(limit >= 0);
            this.limit = limit;
        }

        /**
         * @see OutputStream#close()
         */
        public void close() throws IOException {
            if (closed)
                return;
            try {
                flush();
                if (limit > 0)
                    throw new IOException(Policy.bind("exception.contentLengthUnderflow")); //$NON-NLS-1$
                readServerResponse();
            } finally {
                closed = true;
            }
        }

        /**
         * @see OutputStream#flush()
         */
        public void flush() throws IOException {
            if (closed)
                throw new IOException(Policy.bind("exception.closed")); //$NON-NLS-1$
            socketOut.flush();
        }

        /**
         * @see OutputStream#write(int)
         */
        public void write(int data) throws IOException {
            if (closed)
                throw new IOException(Policy.bind("exception.closed")); //$NON-NLS-1$
            if (limit == 0)
                throw new IOException(Policy.bind("exception.contentLengthExceeded")); //$NON-NLS-1$
            --limit;
            socketOut.write(data);
        }

        /**
         * @see OutputStream#write(byte[], int, int)
         */
        public void write(byte[] buffer, int offset, int count) throws IOException {
            if (closed)
                throw new IOException(Policy.bind("exception.closed")); //$NON-NLS-1$
            if (count > limit)
                throw new IOException(Policy.bind("exception.contentLengthExceeded")); //$NON-NLS-1$
            limit -= count;
            socketOut.write(buffer, offset, count);
        }
    }

    /**
     * A chunked output stream writes bytes to this connection's
     * underlying socket output stream. The bytes are chunked transfer
     * encoded before they are written to the stream.
     */
    private class ChunkedOutputStream extends OutputStream {
        // The maximum size of this output stream's buffer
        private static final int MAX_BUFFER_SIZE = 1024;

        // A buffer that holds the next chunk of data to be written to
        // this output stream.
        private ByteArrayOutputStream buffer = new ByteArrayOutputStream(MAX_BUFFER_SIZE);

        // A boolean indicating whether this output stream is closed.
        private boolean closed = false;

        /**
         * @see OutputStream#close()
         */
        public void close() throws IOException {
            if (closed)
                return;
            try {
                sendBuffer();
                output(socketOut, "0\r\n\r\n"); //$NON-NLS-1$
                readServerResponse();
            } finally {
                closed = true;
            }
        }

        /**
         * @see OutputStream#flush()
         */
        public void flush() throws IOException {
            if (closed)
                throw new IOException("closed"); //$NON-NLS-1$
            sendBuffer();
            socketOut.flush();
        }

        /**
         * Writes the content of the buffer to this stream.
         */
        public void sendBuffer() throws IOException {
            int chunkSize = buffer.size();
            if (chunkSize > 0) {
                output(socketOut, Integer.toHexString(chunkSize) + "\r\n"); //$NON-NLS-1$
                buffer.writeTo(socketOut);
                buffer.reset();
                output(socketOut, "\r\n"); //$NON-NLS-1$
            }
        }

        /**
         * @see OutputStream#write(int)
         */
        public void write(int data) throws IOException {
            if (closed)
                throw new IOException(Policy.bind("exception.closed")); //$NON-NLS-1$
            buffer.write(data);
            if (buffer.size() >= MAX_BUFFER_SIZE)
                sendBuffer();
        }

        /**
         * @see OutputStream#write(byte[], int, int)
         */
        public void write(byte[] buf, int off, int len) throws IOException {
            if (closed)
                throw new IOException(Policy.bind("exception.closed")); //$NON-NLS-1$
            int bufferSize = buffer.size();
            if (bufferSize + len < MAX_BUFFER_SIZE) {
                buffer.write(buf, off, len);
            } else {
                output(socketOut, Integer.toHexString(bufferSize + len) + "\r\n"); //$NON-NLS-1$
                buffer.writeTo(socketOut);
                buffer.reset();
                socketOut.write(buf, off, len);
                output(socketOut, "\r\n"); //$NON-NLS-1$
            }
        }
    }

    /**
     * A cached output stream writes bytes to a cache before sending the
     * data to the underlying socket output stream. This is done so the
     * content length can be determined.
     */
    private class CachedOutputStream extends OutputStream {
        // The initial size of this output stream's cache
        private static final int INITIAL_CACHE_SIZE = 1024;

        // A cache that stores the bytes written to this output stream.
        private ByteArrayOutputStream cache = new ByteArrayOutputStream(INITIAL_CACHE_SIZE);

        // A boolean indicating whether this output stream is closed.
        private boolean closed = false;

        /**
         * @see OutputStream#close()
         */
        public void close() throws IOException {
            if (closed)
                return;
            try {
                String contentLength = Integer.toString(cache.size());
                internalHeader.addField("Content-Length", contentLength); //$NON-NLS-1$
                sendRequest();
                cache.writeTo(socketOut);
                readServerResponse();
            } finally {
                closed = true;
            }
        }

        /**
         * @see OutputStream#flush()
         */
        public void flush() throws IOException {
            if (closed)
                throw new IOException(Policy.bind("exception.closed")); //$NON-NLS-1$
        }

        /**
         * @see OutputStream#write(int)
         */
        public void write(int data) throws IOException {
            if (closed)
                throw new IOException(Policy.bind("exception.closed")); //$NON-NLS-1$
            cache.write(data);
        }

        /**
         * @see OutputStream#write(byte[], int, int)
         */
        public void write(byte[] buffer, int offset, int count) throws IOException {
            if (closed)
                throw new IOException(Policy.bind("exception.closed")); //$NON-NLS-1$
            cache.write(buffer, offset, count);
        }
    }

    /**
     * Creates a new connection on the specified resource.
     *
     * @param resourceUrl the <code>URL</code> of a resource
     */
    public HttpConnection(URL resourceUrl) {
        Assert.isNotNull(resourceUrl);
        this.resourceUrl = resourceUrl;
    }

    /**
     * Creates a new connection on the specified resource. This connection
     * communicates through the proxy at the given proxy server
     * <code>URL</code>.
     *
     * @param proxyServerUrl the <code>URL</code> of a proxy server
     * @param resourceUrl the <code>URL</code> of a resource
     */
    public HttpConnection(URL proxyServerUrl, URL resourceUrl) {
        Assert.isNotNull(proxyServerUrl);
        Assert.isNotNull(resourceUrl);
        this.proxyServerUrl = proxyServerUrl;
        this.resourceUrl = resourceUrl;
    }

    /**
     * Clear the request header.
     */
    public void clearRequestHeader() {
        endRequest();
        requestHeader.clear();
    }

    /**
     * Close this connection.
     *
     * @exception IOException if there is an I/O error closing the socket
     */
    public void close() throws IOException {
        endRequest();
        if (connected) {
            connected = false;
            socket.close();
        }
    }

    private void connect() throws IOException {
        if (!connected) {
            String protocol;
            String host;
            int port;
            if (proxyServerUrl == null) {
                protocol = resourceUrl.getProtocol();
                host = resourceUrl.getHost();
                port = getPort(resourceUrl);
            } else {
                protocol = proxyServerUrl.getProtocol();
                host = proxyServerUrl.getHost();
                port = getPort(proxyServerUrl);
            }

            if (socketFactory == null) {
                socket = new Socket(host, port);
            } else {
                socket = socketFactory.createSocket(protocol, host, port);
            }

            if (receiveBufferSize > 0) {
                socket.setReceiveBufferSize(receiveBufferSize);
            }

            if (sendBufferSize > 0) {
                socket.setSendBufferSize(sendBufferSize);
            }

            socket.setSoLinger(soLinger >= 0, soLinger >= 0 ? soLinger : 0);
            socket.setSoTimeout(soTimeout);
            socket.setTcpNoDelay(tcpNoDelay);

            socketOut = new BufferedOutputStream(socket.getOutputStream());
            socketIn = new BufferedInputStream(socket.getInputStream());

            closeConnection = httpVersion == 1.0 || !persistent;

            connected = true;
        }
    }

    private void endRequest() {
        if (sentRequest) {
            boolean failed = false;

            if (os != null) {
                try {
                    os.close();
                } catch (IOException e) {
                    failed = true;
                }
            }

            try {
                getInputStream().close();
            } catch (IOException e) {
                failed = true;
            }

            sentRequest = false;

            if (closeConnection || failed) {
                try {
                    connected = false;
                    socket.close();
                } catch (IOException e) {
                    // ignore or log?
                }
            }
        }

        os = null;
        is = null;

        internalHeader.clear();
    }

    /**
     * Returns the version of HTTP this connection uses for communication
     * with servers. HTTP/1.1 is used by default.
     *
     * @return the version of HTTP this connection uses for communication
     * with servers
     * @see #setHttpVersion(double)
     */
    public double getHttpVersion() {
        return httpVersion;
    }

    /**
     * Returns this connection's <code>InputStream</code>.
     *
     * @return this connection's <code>InputStream</code>
     * @exception IOException if an I/O error occurs while sending the
     * request
     */
    public InputStream getInputStream() throws IOException {
        if (is != null)
            return is;
        sendRequest();
        String transferEncoding = responseHeader.getFieldValue("Transfer-Encoding"); //$NON-NLS-1$
        String contentLength = responseHeader.getFieldValue("Content-Length"); //$NON-NLS-1$
        if ("chunked".equalsIgnoreCase(transferEncoding)) { //$NON-NLS-1$
            is = new ChunkedInputStream();
        } else if (method.equals("HEAD") && statusCode == HTTP_OK) { //$NON-NLS-1$
            is = new LimitedInputStream(0);
        } else if (contentLength != null) {
            try {
                is = new LimitedInputStream(Integer.parseInt(contentLength));
            } catch (NumberFormatException e) {
                throw new IOException(e.getMessage());
            }
        } else if ((statusCode >= 100 && statusCode < 200) || statusCode == HTTP_NO_CONTENT || statusCode == HTTP_NOT_MODIFIED) {
            is = new LimitedInputStream(0);
        } else {
            closeConnection = true;
            is = socketIn;
        }
        return is;
    }

    /**
     * Returns this connection's <code>OutputStream</code>.
     *
     * @return this connection's <code>OutputStream</code>
     * @exception IOException if an I/O error occurs while sending the
     * request
     */
    public OutputStream getOutputStream() throws IOException {
        if (os != null)
            return os;
        String contentLength = requestHeader.getFieldValue("Content-Length"); //$NON-NLS-1$
        if (sendChunked && httpVersion > 1.0) {
            os = new ChunkedOutputStream();
        } else if (contentLength != null) {
            try {
                os = new LimitedOutputStream(Integer.parseInt(contentLength));
            } catch (NumberFormatException e) {
                throw new IOException(Policy.bind("exception.malformedContentLength")); //$NON-NLS-1$
            }
        } else {
            os = new CachedOutputStream();
            return os;
        }
        sendRequest();
        return os;
    }

    /**
     * Returns a boolean indicating whether this connection should remain
     * open after each request.
     *
     * @return a boolean indicating whether this connection should remain
     * open after each request
     * @see #setPersistent(boolean)
     */
    public boolean getPersistent() {
        return persistent;
    }

    private int getPort(URL url) {
        String protocol = url.getProtocol();
        int port = url.getPort();

        if (port == -1) {
            if (protocol.equals("http")) //$NON-NLS-1$
                return 80;
            if (protocol.equals("https")) //$NON-NLS-1$
                return 443;
            return -1;
        }

        return port;
    }

    /**
     * Returns the <code>URL</code> of the proxy server this connection uses
     * to communicate with the origin server, or <code>null</code> if a proxy
     * server is not used.
     *
     * @return the <code>URL</code> of the proxy server this connection uses
     * to communicate with the origin server
     * @see #setProxyServerUrl(URL)
     */
    public URL getProxyServerUrl() {
        return proxyServerUrl;
    }

    /**
     * @see Socket#getReceiveBufferSize()
     * @see #setReceiveBufferSize(int)
     */
    public int getReceiveBufferSize() throws IOException {
        if (connected)
            return socket.getReceiveBufferSize();
        return receiveBufferSize;
    }

    /**
     * Returns the request header value associated with the given field name,
     * or <code>null</code> if there is no such field name.
     *
     * @param fieldName the request header field name
     * @return the request header value associated with the given field name
     * @see #setRequestHeaderField(String, String)
     */
    public String getRequestHeaderFieldValue(String fieldName) {
        Assert.isNotNull(fieldName);
        return requestHeader.getFieldValue(fieldName);
    }

    /**
     * Returns the request method. "GET" is used by default.
     *
     * @return the request method
     * @see #setRequestMethod(String)
     */
    public String getRequestMethod() {
        return method;
    }

    /**
     * Returns the <code>URL</code> of this connection's resource.
     *
     * @return the <code>URL</code> of this connection's resource
     * @see #setResourceUrl(URL)
     */
    public URL getResourceUrl() {
        return resourceUrl;
    }

    /**
     * Returns the response header field name at the given position, or
     * <code>null</code> if there is no field name at that position.
     *
     * @param position a position in the response header greater than or
     * equal to zero
     * @return the response header field name at the given postion
     * @exception IOException if an I/O error occurs while sending the
     * request
     * @see #getResponseHeaderFieldValue(int)
     * @see #getResponseHeaderFieldValue(String)
     */
    public String getResponseHeaderFieldName(int position) throws IOException {
        Assert.isTrue(position >= 0);
        sendRequest();
        return responseHeader.getFieldName(position);
    }

    /**
     * Returns the response header field value at the given position, or
     * <code>null</code> if there is no value at that position.
     *
     * @param position a position in the response header greater than or
     * equal to zero
     * @return the response header field value at the given postion
     * @exception IOException if an I/O error occurs while sending the
     * request
     * @see #getResponseHeaderFieldName(int)
     * @see #getResponseHeaderFieldValue(String)
     */
    public String getResponseHeaderFieldValue(int position) throws IOException {
        Assert.isTrue(position >= 0);
        sendRequest();
        return responseHeader.getFieldValue(position);
    }

    /**
     * Returns the response header field value that is associated with the
     * given field name, or <code>null</code> if there is no value associated
     * with that field name.
     *
     * @param fieldName the name of a response header field
     * @return the response header field value that is associated with the
     * given field name
     * @exception IOException if an I/O error occurs while sending the
     * request
     * @see #getResponseHeaderFieldValue(int)
     * @see #getResponseHeaderFieldName(int)
     */
    public String getResponseHeaderFieldValue(String fieldName) throws IOException {
        Assert.isNotNull(fieldName);
        sendRequest();
        return responseHeader.getFieldValue(fieldName);
    }

    /**
     * @see Socket#getSendBufferSize()
     * @see #setSendBufferSize(int)
     */
    public int getSendBufferSize() throws IOException {
        if (connected)
            return socket.getSendBufferSize();
        return sendBufferSize;
    }

    /**
     * Returns a boolean indicating whether the request's body should be sent
     * chunked encoded.
     *
     * @return a boolean indicating whether the request's body should be sent
     * chunked encoded
     * @see #setSendChunked(boolean)
     */
    public boolean getSendChunked() {
        return sendChunked;
    }

    /**
     * @see Socket#getSoLinger()
     * @see #setSoLinger(boolean, int)
     */
    public int getSoLinger() {
        return soLinger;
    }

    /**
     * @see Socket#getSoTimeout()
     * @see #setSoTimeout(int)
     */
    public int getSoTimeout() {
        return soTimeout;
    }

    /**
     * Returns the status code of the server's response.
     *
     * @return the status code of the server's response
     * @exception IOException if an I/O error occurs while sending the
     * request
     * @see #getStatusMessage()
     */
    public int getStatusCode() throws IOException {
        sendRequest();
        return statusCode;
    }

    /**
     * Returns the status message of the server's response.
     *
     * @return the status message of the server's response
     * @exception IOException if an I/O error occurs while sending the
     * request
     * @see #getStatusCode()
     */
    public String getStatusMessage() throws IOException {
        sendRequest();
        return statusMessage;
    }

    /**
     * @see Socket#getTcpNoDelay()
     * @see #setTcpNoDelay(boolean)
     */
    public boolean getTcpNoDelay() {
        return tcpNoDelay;
    }

    /**
     * Returns this connection's timestamp.
     *
     * @return this connection's timestamp
     * @see #setTimestamp(Date)
     */
    public Date getTimestamp() {
        return timestamp;
    }

    protected void output(OutputStream stream, String output) throws IOException {
        stream.write(output.getBytes("UTF8")); //$NON-NLS-1$
    }

    protected void readHeader(Header header) throws IOException {
        String line = null;

        while ((line = readln()).length() > 0) {
            int index = line.indexOf(":"); //$NON-NLS-1$

            if (index < 0)
                throw new IOException(Policy.bind("exception.malformedHeaderField")); //$NON-NLS-1$
            String fieldName = line.substring(0, index);
            String fieldValue = line.substring(index + 1).trim();
            header.addField(fieldName, fieldValue);
        }

        String fieldValue = header.getFieldValue("Connection"); //$NON-NLS-1$
        if (fieldValue != null && fieldValue.equalsIgnoreCase("close")) { //$NON-NLS-1$
            closeConnection = true;
        }
    }

    protected String readln() throws IOException {
        boolean foundCR = false;
        StringBuffer result = new StringBuffer();

        while (true) {
            int c = socketIn.read();

            if (c < 0) {
                throw new IOException(Policy.bind("exception.unexpectedEndStream")); //$NON-NLS-1$
            }

            if (foundCR) {
                if (c == '\n')
                    break;
                result.append('\r');
            }

            if (c == '\r') {
                foundCR = true;
            } else {
                result.append((char) c);
            }
        }
        return result.toString();
    }

    protected void readServerResponse() throws IOException {
        socketOut.flush();

        String statusLine = readln();

        if (!statusLine.startsWith("HTTP/")) { //$NON-NLS-1$
            throw new IOException(Policy.bind("exception.malformedStatusLine")); //$NON-NLS-1$
        }

        int firstSpace = statusLine.indexOf(' ', 5);
        if (firstSpace == -1) {
            throw new IOException(Policy.bind("exception.malformedStatusLine")); //$NON-NLS-1$
        }

        int secondSpace = statusLine.indexOf(' ', firstSpace + 1);
        if (secondSpace == -1) {
            throw new IOException(Policy.bind("exception.malformedStatusLine")); //$NON-NLS-1$
        }

        double protocolVersion;

        try {
            protocolVersion = Double.parseDouble(statusLine.substring(5, firstSpace));
            statusCode = Integer.parseInt(statusLine.substring(firstSpace + 1, secondSpace));
            statusMessage = statusLine.substring(secondSpace + 1);
        } catch (NumberFormatException e) {
            throw new IOException(Policy.bind("exception.malformedStatusLine")); //$NON-NLS-1$
        }

        if (protocolVersion == 1.0) {
            httpVersion = 1.0;
            closeConnection = true;
        }

        responseHeader.clear();
        readHeader(responseHeader);
    }

    protected void sendRequest() throws IOException {
        if (sentRequest)
            return;
        connect();
        if (requestHeader.getFieldValue("Host") == null) { //$NON-NLS-1$
            internalHeader.addField("Host", //$NON-NLS-1$
                    resourceUrl.getHost() + (resourceUrl.getPort() == -1 ? "" : ":" + resourceUrl.getPort())); //$NON-NLS-1$ //$NON-NLS-2$
        }

        if (httpVersion > 1.0 && !persistent && requestHeader.getFieldValue("Connection") == null) { //$NON-NLS-1$
            internalHeader.addField("Connection", "close"); //$NON-NLS-1$ //$NON-NLS-2$
        }

        if (os != null || "100-continue".equals(requestHeader.getFieldValue("Expect"))) { //$NON-NLS-1$ //$NON-NLS-2$
            if (requestHeader.getFieldValue("Content-Type") == null) { //$NON-NLS-1$
                internalHeader.addField("Content-Type", "application/x-www-form-urlencoded"); //$NON-NLS-1$ //$NON-NLS-2$
            }
            if (httpVersion > 1.0 && sendChunked) {
                internalHeader.addField("Transfer-Encoding", "chunked"); //$NON-NLS-1$ //$NON-NLS-2$
            }
        }

        output(socketOut, method);
        output(socketOut, " "); //$NON-NLS-1$
        output(socketOut, proxyServerUrl == null ? resourceUrl.getFile() : resourceUrl.toString());
        output(socketOut, " HTTP/1.1\r\n"); //$NON-NLS-1$

        writeHeader(internalHeader);
        writeHeader(requestHeader);
        output(socketOut, "\r\n"); //$NON-NLS-1$

        sentRequest = true;

        if (os == null) {
            readServerResponse();
        }
    }

    /**
     * Sets the version of HTTP this connection uses for communication with
     * servers. HTTP/1.1 is used by default.
     *
     * @param version the version of HTTP this connection uses for
     * communication with servers
     * @see #getHttpVersion()
     */
    public void setHttpVersion(double version) {
        Assert.isTrue(version == 1.0 || version == 1.1);
        endRequest();
        httpVersion = version;
    }

    /**
     * Sets a boolean indicating whether this connection should remain open
     * after each request.
     *
     * @param close a boolean indicating whether this connection should
     * remain open after each request
     * @see #getPersistent()
     */
    public void setPersistent(boolean close) {
        endRequest();
        persistent = close;
    }

    /**
     * Sets the <code>URL</code> of the proxy server this connection uses to
     * communicate with the origin server. If <code>null</code> is given, no
     * proxy server is used.
     *
     * @param proxyServerUrl the <code>URL</code> of a proxy server
     * @see #getProxyServerUrl()
     */
    public void setProxyServerUrl(URL proxyServerUrl) {
        endRequest();
        if (proxyServerUrl == null && this.proxyServerUrl == null)
            return;
        boolean closeConnection = true;

        if (proxyServerUrl != null && this.proxyServerUrl != null) {
            URL oldProxyServerUrl = null;
            URL newProxyServerUrl = null;

            try {
                oldProxyServerUrl = new URL(this.proxyServerUrl.getProtocol(), this.proxyServerUrl.getHost(), this.proxyServerUrl.getPort(), "/"); //$NON-NLS-1$
                newProxyServerUrl = new URL(proxyServerUrl.getProtocol(), proxyServerUrl.getHost(), proxyServerUrl.getPort(), "/"); //$NON-NLS-1$
            } catch (MalformedURLException e) {
                // ignore or log?
            }

            if (oldProxyServerUrl.equals(newProxyServerUrl)) {
                closeConnection = false;
            }
        }

        if (closeConnection) {
            try {
                close();
            } catch (IOException e) {
                // ignore or log?
            }
        }

        this.proxyServerUrl = proxyServerUrl;
    }

    /**
     * @see Socket#setReceiveBufferSize(int)
     * @see #getReceiveBufferSize()
     */
    public void setReceiveBufferSize(int size) throws IOException {
        Assert.isTrue(size > 0);
        if (size != getReceiveBufferSize()) {
            receiveBufferSize = size;
            if (connected) {
                socket.setReceiveBufferSize(receiveBufferSize);
            }
        }
    }

    /**
     * Sets the request header value associated with the given field.
     *
     * @param fieldName the request header field
     * @param fieldValue the request header value
     * @see #getRequestHeaderFieldValue(String)
     */
    public void setRequestHeaderField(String fieldName, String fieldValue) {
        Assert.isNotNull(fieldName);
        Assert.isNotNull(fieldValue);
        endRequest();
        requestHeader.addField(fieldName, fieldValue);
    }

    /**
     * Sets the request method. "GET" is used by default.
     *
     * @param method the request method
     * @see #getRequestMethod()
     */
    public void setRequestMethod(String method) {
        Assert.isNotNull(method);
        endRequest();
        this.method = method;
    }

    /**
     * Sets the <code>URL</code> of this connection's resource.
     *
     * @param resourceUrl the <code>URL</code> of this connection's resource
     * @see #getResourceUrl()
     */
    public void setResourceUrl(URL resourceUrl) {
        Assert.isNotNull(resourceUrl);
        endRequest();
        URL oldOriginServerUrl = null;
        URL newOriginServerUrl = null;
        try {
            oldOriginServerUrl = new URL(this.resourceUrl.getProtocol(), this.resourceUrl.getHost(), this.resourceUrl.getPort(), "/"); //$NON-NLS-1$
            newOriginServerUrl = new URL(resourceUrl.getProtocol(), resourceUrl.getHost(), resourceUrl.getPort(), "/"); //$NON-NLS-1$
        } catch (MalformedURLException e) {
            // ignore?
        }
        if (!oldOriginServerUrl.equals(newOriginServerUrl)) {
            try {
                close();
            } catch (IOException e) {
                // ignore?
            }
        }
        this.resourceUrl = resourceUrl;
    }

    /**
     * @see Socket#setSendBufferSize(int)
     * @see #getSendBufferSize()
     */
    public void setSendBufferSize(int size) throws IOException {
        Assert.isTrue(size > 0);
        if (size != getSendBufferSize()) {
            sendBufferSize = size;
            if (connected) {
                socket.setSendBufferSize(sendBufferSize);
            }
        }
    }

    /**
     * Sets a boolean indicating whether the request's body should be sent
     * chunked encoded.
     *
     * @param chunked a boolean indicating whether the request's body should
     * be sent chunked encoded
     * @see #getSendChunked()
     */
    public void setSendChunked(boolean chunked) {
        endRequest();
        sendChunked = chunked;
    }

    /**
     * Sets the factory this connection uses to create sockets. If the given
     * socket factory is <code>null</code> the default socket is used.
     *
     * @param socketFactory the factory this connection uses to create
     * sockets
     */
    public void setSocketFactory(ISocketFactory socketFactory) {
        endRequest();
        if (socketFactory == this.socketFactory)
            return;
        try {
            close();
        } catch (IOException e) {
            // ignore?
        }
        this.socketFactory = socketFactory;
    }

    /**
     * @see Socket#setSoLinger(boolean, int)
     * @see #getSoLinger()
     */
    public void setSoLinger(boolean on, int linger) throws IOException {
        Assert.isTrue(linger >= 0);
        if (!on && soLinger != -1 || on && linger != soLinger) {
            soLinger = on ? linger : -1;
            if (connected) {
                socket.setSoLinger(on, linger);
            }
        }
    }

    /**
     * @see Socket#setSoTimeout(int)
     * @see #getSoTimeout()
     */
    public void setSoTimeout(int timeout) throws IOException {
        Assert.isTrue(timeout >= 0);
        if (timeout != soTimeout) {
            soTimeout = timeout;
            if (connected) {
                socket.setSoTimeout(soTimeout);
            }
        }
    }

    /**
     * @see Socket#setTcpNoDelay(boolean)
     * @see #getTcpNoDelay()
     */
    public void setTcpNoDelay(boolean on) throws IOException {
        if (on != tcpNoDelay) {
            tcpNoDelay = on;
            if (connected) {
                socket.setTcpNoDelay(tcpNoDelay);
            }
        }
    }

    /**
     * Sets this connection's timestamp.
     *
     * @param date this connection's timestamp
     * @see #getTimestamp()
     */
    public void setTimestamp(Date date) {
        timestamp = date;
    }

    private void writeHeader(Header header) throws IOException {
        for (int i = 0; i < header.size(); ++i) {
            String fieldName = header.getFieldName(i);
            String fieldValue = header.getFieldValue(fieldName);
            output(socketOut, fieldName);
            output(socketOut, ": "); //$NON-NLS-1$
            output(socketOut, fieldValue);
            output(socketOut, "\r\n"); //$NON-NLS-1$
        }
    }
}
