/*
 *  PHEX - The pure-java Gnutella-servent.
 *  Copyright (C) 2001 - 2004 Gregor Koukkoullis ( phex <at> kouk <dot> de )
 *
 *  This program is free software; you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation; either version 2 of the License, or
 *  (at your option) any later version.
 *
 *  This program 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 General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program; if not, write to the Free Software
 *  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 * 
 *  --- CVS Information ---
 *  $Id: DownloadEngine.java,v 1.43 2004/09/22 07:17:00 nxf Exp $
 */
package phex.download;

import java.io.*;
import java.net.*;
import java.util.*;

import org.apache.commons.collections.CollectionUtils;


import phex.common.*;
import phex.common.bandwidth.BandwidthManager;
import phex.connection.*;
import phex.host.*;
import phex.http.*;
import phex.utils.*;

/**
 * This class is responsible to download a file using a HTTP connection.
 * The DownloadEngine is usually managed by a SWDownloadWorker.
 */
public class DownloadEngine
{
    private static final int BUFFER_LENGTH = 1024;
    private IDownloadCandidate candidate;
    private IDownloadSegment segment;

    /**
     * The download file object the big parent of all the download stuff of this
     * file.
     */
    private IDownloadFile downloadFile;

    private RandomAccessFile raFile;
    private Socket socket;
    private GnutellaInputStream inStream;
    private boolean isPersistentConnection;
    private boolean isAcceptingNextSegment;

    /**
     * The position in the destination file where the downloaded data is added to.
     */
    private long fileOffset;

    /**
     * Create a download engine
     * @param aDownloadFile the file to download
     * @param aCandidate the candidate to download the file from.
     */
    public DownloadEngine( IDownloadFile aDownloadFile,
        IDownloadCandidate aCandidate )
    {
        downloadFile = aDownloadFile;
        candidate = aCandidate;
    }

    public DownloadEngine( Socket aSocket, IDownloadFile aDownloadFile,
        IDownloadCandidate aCandidate )
    {
        this( aDownloadFile, aCandidate );
        socket = aSocket;
    }

    public void connect( int timeout )
        throws IOException
    {
        // we don't have a connected socket..
        if ( socket == null )
        {
            HostAddress address = candidate.getHostAddress();
            Logger.logMessage( Logger.FINE, Logger.DOWNLOAD,
                "DownloadEngine - Connecting to " + address.getHostName() + ":"
                + address.getPort() );
            try
            {
                socket = SocketProvider.connect( address, timeout );
            }
            catch ( SocketException exp )
            {// indicates a general communication error while connecting
                throw new ConnectionFailedException( exp.getMessage() );
            }
        }
        // use download throttle controller to limit bandwith
        inStream = new GnutellaInputStream( new BandwidthInputStream(
            socket.getInputStream(),
            BandwidthManager.getInstance().getDownloadBandwidthController() ) );
        Logger.logMessage( Logger.FINE, Logger.DOWNLOAD,
            "Download Engine connected successfully.");
    }

    public void exchangeHTTPHandshake( IDownloadSegment aSegment )
        throws IOException, UnusableHostException, HTTPMessageException
    {
        isAcceptingNextSegment = false;
        segment = aSegment;
        long downloadOffset = segment.getTransferStartPosition();
        fileOffset = segment.getTransferredDataSize();

        OutputStreamWriter writer = new OutputStreamWriter(
            socket.getOutputStream() );

        String requestURI;
        URN resourceURN = candidate.getResourceURN();
        if ( resourceURN != null )
        {
            requestURI = URLUtil.buildName2ResourceURL( resourceURN );
        }
        else
        {
            String fileIndexStr = String.valueOf( candidate.getFileIndex() );
            String fileName = candidate.getFileName();
            StringBuffer uriBuffer = new StringBuffer( 6 + fileIndexStr.length()
                + fileName.length() );
            uriBuffer.append( "/get/" );
            uriBuffer.append( fileIndexStr );
            uriBuffer.append( '/' );            
            uriBuffer.append( URLCodecUtils.encodeURL( fileName ) );
            requestURI = uriBuffer.toString();
        }
        
        long downloadStopPos = segment.getEndOffset() - 1;
        
        HTTPRequest request = new HTTPRequest( "GET", requestURI, true );
        request.addHeader( new HTTPHeader( HTTPHeaderNames.HOST,
            candidate.getHostAddress().getFullHostName() ) );
        request.addHeader( new HTTPHeader( GnutellaHeaderNames.LISTEN_IP,
             NetworkManager.getInstance().getLocalAddress().getFullHostName() ) );
        request.addHeader( new HTTPHeader( HTTPHeaderNames.RANGE,
            "bytes=" + downloadOffset + "-" + downloadStopPos ) );
        request.addHeader( new HTTPHeader( GnutellaHeaderNames.X_QUEUE,
            "0.1" ) );
        // request a HTTP keep alive connection, needed for queuing to work.
        request.addHeader( new HTTPHeader( HTTPHeaderNames.CONNECTION,
            "Keep-Alive" ) );

        buildAltLocRequestHeader(request);

        if ( ServiceManager.sCfg.isChatEnabled )
        {
            HostAddress ha = NetworkManager.getInstance().getLocalAddress();
            if ( !(ha.isLocalHost() || ha.isPrivateIP() ) )
            {
                request.addHeader( new HTTPHeader( "Chat", ha.getFullHostName() ) );
            }
        }

        String httpRequestStr = request.buildHTTPRequestString();

        Logger.logMessage( Logger.FINE, Logger.DOWNLOAD,
            "HTTP Request: " + httpRequestStr );
        // write request...
        writer.write( httpRequestStr );
        writer.flush();

        HTTPResponse response = HTTPProcessor.parseHTTPResponse( inStream );
        Logger.logMessage( Logger.FINE, Logger.DOWNLOAD,
            "HTTP Response: " + response.buildHTTPResponseString() );

        HTTPHeader header = response.getHeader( HTTPHeaderNames.SERVER );
        if ( header != null )
        {
            candidate.setVendor( header.getValue() );
        }

        header = response.getHeader( HTTPHeaderNames.CONTENT_RANGE );
        if ( header != null )
        {
            int replayStartRange = parseStartOffset( header.getValue() );
            if ( replayStartRange != downloadOffset )
            {
                throw new IOException( "Invalid 'CONTENT-RANGE' start offset." );
            }
        }

        
        URN downloadFileURN = downloadFile.getFileURN();
        ArrayList contentURNHeaders = new ArrayList();
        header = response.getHeader( GnutellaHeaderNames.X_GNUTELLA_CONTENT_URN );
        if ( header != null )
        {
            contentURNHeaders.add( header );
        }
        // Shareaza 1.8.10.4 send also a bitprint urn in multiple X-Content-URN headers!
        HTTPHeader[] headers = response.getHeaders( GnutellaHeaderNames.X_CONTENT_URN );
        CollectionUtils.addAll( contentURNHeaders, headers );
        if ( downloadFileURN != null )
        {
            Iterator contentURNIterator = contentURNHeaders.iterator();
            while ( contentURNIterator.hasNext() )
            {
                header = (HTTPHeader)contentURNIterator.next();
                String contentURNStr = header.getValue();
                // check if I can understand urn.
                if ( URN.isValidURN( contentURNStr ) )
                {
                    URN contentURN = new URN( contentURNStr );
                    if ( !downloadFileURN.equals( contentURN ) )
                    {
                        throw new IOException( "Required URN and content URN do not match." );
                    }
                }
            }
        }

        // check Limewire chat support header.
        header = response.getHeader( GnutellaHeaderNames.CHAT );
        if ( header != null )
        {
            candidate.setChatSupported( true );
        }
        // read out REMOTE-IP header... to update my IP
        header = response.getHeader( GnutellaHeaderNames.REMOTE_IP );
        if ( header != null )
        {
            byte[] remoteIP = HostAddress.parseIP( header.getValue() );
            if ( remoteIP != null )
            {
                HostAddress myAddress = NetworkManager.getInstance().getLocalAddress();
                if ( !Arrays.equals( remoteIP, myAddress.getHostIP() ) )
                {
                    NetworkManager.getInstance().updateLocalAddress( remoteIP );
                }
            }
        }
        
        // check if Keep-Alive connection is accepted
        header = response.getHeader( HTTPHeaderNames.CONNECTION );
        if ( header != null && header.getValue().equalsIgnoreCase( "close" ) )
        {
            isPersistentConnection = false;
        }
        else
        {
            isPersistentConnection = true;
        }

        header = response.getHeader( GnutellaHeaderNames.X_AVAILABLE_RANGES );
        if ( header != null )
        {
            HTTPRangeSet availableRanges =
                HTTPRangeSet.parseHTTPRangeSet( header.getValue() );
            if ( availableRanges == null )
            {// failed to parse... give more detailed error report
                Logger.logError( Logger.DOWNLOAD,
                    "Failed to parse X-Available-Ranges in "
                    + candidate.getVendor() + " request: "
                    + response.buildHTTPResponseString() );
            }
            candidate.setAvailableRangeSet( availableRanges );
        }
        
        // collect alternate locations...
        List altLocList = new ArrayList();
        headers = response.getHeaders( GnutellaHeaderNames.ALT_LOC );
        List altLocTmpList = AlternateLocationContainer.parseUriResAltLocFromHTTPHeaders( headers );
        altLocList.addAll( altLocTmpList );
        
        headers = response.getHeaders( GnutellaHeaderNames.X_ALT_LOC );
        altLocTmpList = AlternateLocationContainer.parseUriResAltLocFromHTTPHeaders( headers );
        altLocList.addAll( altLocTmpList );
        
        headers = response.getHeaders( GnutellaHeaderNames.X_ALT );
        altLocTmpList = AlternateLocationContainer.parseCompactIpAltLocFromHTTPHeaders( headers,
            downloadFileURN );
        altLocList.addAll( altLocTmpList );
        
        Iterator iterator = altLocList.iterator();
        while( iterator.hasNext() )
        {
            downloadFile.addDownloadCandidate( (AlternateLocation)iterator.next() );
        }

        int httpCode = response.getStatusCode();

        if ( httpCode >= 200 && httpCode < 300 )
        {// code accepted
            
            // check if we can accept the urn...
            if ( contentURNHeaders.size() == 0 && resourceURN != null )
            {// we requested a download via /uri-res resource urn.
             // we expect that the result contains a x-gnutella-content-urn
             // or Shareaza X-Content-URN header.
                throw new IOException(
                    "Response to uri-res request without valid Content-URN header." );
            }
            
            // connection successfully finished
            Logger.logMessage( Logger.FINE, Logger.DOWNLOAD,
                "HTTP Handshake successfull.");
            return;
        }
        // check error type
        else if ( httpCode == 503 )
        {// 503 -> host is busy (this can also be returned when remotly queued)
            header = response.getHeader( GnutellaHeaderNames.X_QUEUE );
            XQueueParameters xQueueParameters = null;
            if ( header != null )
            {
                xQueueParameters = XQueueParameters.parseHTTPRangeSet( header.getValue() );
            }
            // check for persistent connection (gtk-gnutella uses queuing with 'Connection: close')
            if ( xQueueParameters != null && isPersistentConnection )
            {
                throw new RemotelyQueuedException( xQueueParameters );
            }
            else
            {
                header = response.getHeader( HTTPHeaderNames.RETRY_AFTER );
                if ( header != null )
                {
                    int delta = HTTPRetryAfter.parseDeltaInSeconds( header );
                    if ( delta > 0 )
                    {
                        throw new HostBusyException( delta );
                    }
                }
                throw new HostBusyException();
            }
        }
        else if ( httpCode == 403 )
        {
            throw new UnusableHostException( "Request Forbidden" );
        }
        else if ( httpCode == 408 )
        {
            // 408 -> Time out. Try later?
            throw new HostBusyException();
        }
        else if ( httpCode == 404 || httpCode == 410 )
        {// 404: File not found / 410: Host not sharing
            throw new FileNotAvailableException();
        }
        else if ( httpCode == 416 )
        {// 416: Requested Range Unavailable
            throw new RangeUnavailableException();
         // TODO3 in case of keep-alive supported connection we can 
         // adjust and rerequest immediatly (job of SWDownloadWorker)
        }
        else
        {
            throw new IOException( "Unknown HTTP code: " + httpCode );
        }
    }

    private void buildAltLocRequestHeader(HTTPRequest request)
    {
        URN downloadFileURN = downloadFile.getFileURN();
        if ( downloadFileURN == null )
        {
            return;
        }
        
        // add good alt loc http header
        
        AlternateLocationContainer altLocContainer = new AlternateLocationContainer(
            downloadFileURN );
        // downloadFile.getGoodAltLocContainer() always returns a alt-loc container
        // when downloadFileURN != null
        altLocContainer.addContainer( downloadFile.getGoodAltLocContainer() );

        // create a temp copy of the container and add local alt location
        // if partial file sharing is active and we are not covered by a firewall
        if ( ServiceManager.sCfg.arePartialFilesShared &&
             NetworkManager.getInstance().hasConnectedIncoming() )
        {
            // add the local peer to the alt loc on creation, but only if its
            // not a private IP
            HostAddress ha = NetworkManager.getInstance().getLocalAddress();
            if ( !ha.isPrivateIP() )
            {
                AlternateLocation newAltLoc = new AlternateLocation( ha,
                    downloadFileURN );
                altLocContainer.addAlternateLocation( newAltLoc );
            }
        }
        
        HTTPHeader header = altLocContainer.getAltLocHTTPHeaderForAddress(
            GnutellaHeaderNames.X_ALT, candidate.getHostAddress(),
            candidate.getSendAltLocsSet() );
        if ( header != null )
        {
            request.addHeader( header );
        }
        
        // add bad alt loc http header
        
        // downloadFile.getBadAltLocContainer() always returns a alt-loc container
        // when downloadFileURN != null
        altLocContainer = downloadFile.getBadAltLocContainer();
        header = altLocContainer.getAltLocHTTPHeaderForAddress(
            GnutellaHeaderNames.X_NALT, candidate.getHostAddress(),
            candidate.getSendAltLocsSet() );
        if ( header != null )
        {
            request.addHeader( header );
        }
        
    }

    public void startDownload( File destFile ) throws IOException
    {
        String snapshotOfSegment;
        Logger.logMessage( Logger.FINE, Logger.DOWNLOAD,
            "Download Engine starts download.");
        boolean downloadSuccessful = false;
        // open file
        raFile = new RandomAccessFile( destFile, "rw" );

        try
        {
            segment.downloadStartNotify();
            snapshotOfSegment = segment.toString();
            raFile.seek( fileOffset );

            int length;
            byte[] buffer = new byte[ BUFFER_LENGTH ];
            long downloadLength = segment.getTransferDataSize();
            long lengthDownloaded = segment.getTransferredDataSize();
            while ( downloadLength > lengthDownloaded )
            {
                long lengthLeft = downloadLength - lengthDownloaded;
                // read the min value of the buffer length or the value left to read
                length = inStream.read( buffer, 0, (int)Math.min( BUFFER_LENGTH, lengthLeft ) );
                //Logger.logMessage( Logger.FINER, Logger.UPLOAD, "Received data " +
                //    length );
                // end of stream
                if ( length == -1 )
                {
                    break;
                }

                synchronized (segment)
                {
                    // if there's no worker for this segment, we're about to merge, and
                    // it's unsafe to insert new data into the file!
                    if ( segment.isBusy() )
                    {
                        raFile.write( buffer, 0, length );
                        lengthDownloaded += length;
                        if ( lengthDownloaded < segment.getTransferredDataSize() )
                        {
                            Logger.logMessage( Logger.SEVERE, Logger.DOWNLOAD,
                                "TransferredDataSize going down!!!! " + " ll "
                                + lengthLeft + " l " + length + " ld " + lengthDownloaded
                                + " gtds " + segment.getTransferredDataSize()
                                + " dl " + downloadLength 
                                + " seg: " + segment 
                                + " originally: " + snapshotOfSegment );
                        }
                        segment.setTransferredDataSize( lengthDownloaded );
                        // get transfer size since it might have changed in the meantime.
                        downloadLength = segment.getTransferDataSize();
                    } else {
                            Logger.logMessage( Logger.FINE, Logger.DOWNLOAD,
                                    "lucky we checked - would have added to a segment with no worker!");
                    }
                }
            }
            downloadSuccessful = true;
        }
        finally
        {
            // this is for keep alive support...
            if ( downloadSuccessful && isPersistentConnection )
            {
                isAcceptingNextSegment = true;
                closeDownloadFile();
            }
            else
            {
                stopDownload();
            }
        }
    }

    private void closeDownloadFile()
    {
        if ( raFile != null )
        {
            try
            {
                raFile.getFD().sync();
            }
            catch ( IOException exp )
            {// ignore...
            }
            try
            {
                raFile.close();
            }
            catch ( IOException exp )
            {
            }
        }
    }

    public void stopDownload()
    {
        Logger.logMessage( Logger.FINER, Logger.DOWNLOAD,
                "Closing pipe and socket and telling segment we've stopped.");

        if ( inStream != null )
        {
            inStream.close();
        }

        if ( segment != null )
        {
            segment.downloadStopNotify();
        }

        closeDownloadFile();

        if ( socket != null )
        {
            try
            {
                socket.close();
            }
            catch ( IOException exp )
            {
            }
        }
    }

    /**
     * Indicates whether the connection is keept alive and the next http request
     * can be send.
     * @return true if the next http request can be send.
     */
    public boolean isAcceptingNextSegment()
    {
        return isAcceptingNextSegment;
    }

    /**
     * We only care for the start offset since this is the importent point to
     * begin the download from. Wherever it ends we try to download as long as we
     * stay connected or until we reach our goal.
     *
     * Possible Content-Range Headers ( maybe not complete / header is upper
     * cased by Phex )
     *
     *   Content-range:bytes abc-def/xyz
     *   Content-range:bytes abc-def\/*
     *   Content-range:bytes *\/xyz
     *   Content-range: bytes=abc-def/xyz (wrong but older Phex version and old clients use this)
     *
     * @param contentRangeLine the content range value
     * @throws WrongHTTPHeaderException if the content range line has wrong format.
     * @return the content range start offset.
     */
    private int parseStartOffset( String contentRangeLine )
        throws WrongHTTPHeaderException
    {
        try
        {
            contentRangeLine = contentRangeLine.toLowerCase();
            // skip over bytes plus extra char
            int idx = contentRangeLine.indexOf( "bytes" ) + 6;
            String rangeStr = contentRangeLine.substring( idx ).trim();
            // covering type:
            // Content-range:bytes *\/xyz
            if ( rangeStr.charAt( 0 ) == '*' )
            {
                return 0;
            }

            // covering type:
            // Content-range:bytes abc-def/xyz
            // Content-range:bytes abc-def\/*
            idx = rangeStr.indexOf( '-' );
            String startOffsetStr = rangeStr.substring( 0, idx );
            int startOffset = Integer.parseInt( startOffsetStr );
            return startOffset;
        }
        catch ( NumberFormatException exp )
        {
            Logger.logWarning( exp );
            throw new WrongHTTPHeaderException(
                "Number error while parsing content range: " + contentRangeLine );
        }
        catch ( IndexOutOfBoundsException exp )
        {
            throw new WrongHTTPHeaderException(
                "Error while parsing content range: " + contentRangeLine );
        }
    }
}
