package net.i2p.sam;
/*
 * free (adj.): unencumbered; not under the control of others
 * Written by human in 2004 and released into the public domain
 * with no warranty of any kind, either expressed or implied.
 * It probably won't  make your computer catch on fire, or eat
 * your children, but it might.  Use at your own risk.
 *
 */

import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.io.OutputStream;
import java.nio.channels.Channels;
import java.nio.Buffer;
import java.nio.ByteBuffer;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;

import net.i2p.I2PException;
import net.i2p.client.streaming.I2PSocket;
import net.i2p.client.streaming.I2PSocketOptions;
import net.i2p.data.ByteArray;
import net.i2p.data.DataFormatException;
import net.i2p.data.DataHelper;
import net.i2p.data.Destination;
import net.i2p.util.ByteCache;
import net.i2p.util.I2PAppThread;
import net.i2p.util.Log;

/**
 * SAMv2 STREAM session class.
 *
 * @author mkvore
 */
class SAMv2StreamSession extends SAMStreamSession
{
		/**
		 * Create a new SAM STREAM session.
		 *
		 * Caller MUST call start().
		 *
		 * @param dest Base64-encoded destination and private keys (same format as PrivateKeyFile)
		 * @param dir Session direction ("RECEIVE", "CREATE" or "BOTH")
		 * @param props Properties to setup the I2P session
		 * @param recv Object that will receive incoming data
		 * @throws IOException
		 * @throws DataFormatException
		 * @throws SAMException 
		 */
		public SAMv2StreamSession ( String dest, String dir, Properties props,
		                            SAMStreamReceiver recv ) throws IOException, DataFormatException, SAMException
		{
			super ( dest, dir, props, recv );
		}

		/**
		 * Create a new SAM STREAM session.
		 *
		 * Caller MUST call start().
		 *
		 * @param destStream Input stream containing the destination and private keys (same format as PrivateKeyFile)
		 * @param dir Session direction ("RECEIVE", "CREATE" or "BOTH")
		 * @param props Properties to setup the I2P session
		 * @param recv Object that will receive incoming data
		 * @throws IOException
		 * @throws DataFormatException
		 * @throws SAMException 
		 */
		public SAMv2StreamSession ( InputStream destStream, String dir,
		                            Properties props,  SAMStreamReceiver recv ) throws IOException, DataFormatException, SAMException
		{
			super ( destStream, dir, props, recv );
		}

		/**
		 * Connect the SAM STREAM session to the specified Destination
		 *
		 * @param id Unique id for the connection
		 * @param dest Base64-encoded Destination to connect to
		 * @param props Options to be used for connection
		 *
		 * @throws DataFormatException if the destination is not valid
		 * @throws SAMInvalidDirectionException if trying to connect through a
		 *                                      receive-only session
		 * @return true if the communication with the SAM client is ok
		 */
		@Override
		public boolean connect ( int id, String dest, Properties props )
		throws DataFormatException, SAMInvalidDirectionException
		{
			if ( !canCreate )
			{
				if (_log.shouldLog(Log.DEBUG))
					_log.debug ( "Trying to create an outgoing connection using a receive-only session" );
				throw new SAMInvalidDirectionException ( "Trying to create connections through a receive-only session" );
			}

			if ( checkSocketHandlerId ( id ) )
			{
				if (_log.shouldLog(Log.DEBUG))
					_log.debug ( "The specified id (" + id + ") is already in use" );
				return false ;
			}

			Destination d = SAMUtils.getDest(dest);
			I2PSocketOptions opts = socketMgr.buildOptions ( props );
			if ( props.getProperty ( I2PSocketOptions.PROP_CONNECT_TIMEOUT ) == null )
				opts.setConnectTimeout ( 60 * 1000 );

			if (_log.shouldLog(Log.DEBUG))
				_log.debug ( "Connecting new I2PSocket..." );

			// non-blocking connection (SAMv2)
			StreamConnector connector = new StreamConnector ( id, d, opts );
			I2PAppThread connectThread = new I2PAppThread ( connector, "StreamConnector" + id ) ;
			connectThread.start() ;
			return true ;
		}

		/**
		 * SAM STREAM socket connecter, running in its own thread.  
		 *
		 * @author mkvore
		 */
		private class StreamConnector implements Runnable
		{
				private final int id;
				private final Destination      dest ;
				private final I2PSocketOptions opts ;

				/**
				 * Create a new SAM STREAM session socket reader
				 *
				 * @param id   Unique id assigned to the handler
				 * @param dest Destination to reach
				 * @param opts Socket options (I2PSocketOptions)
				*/

				public StreamConnector ( int id, Destination dest, I2PSocketOptions opts )// throws IOException
				{
					if (_log.shouldLog(Log.DEBUG))
						_log.debug ( "Instantiating new SAM STREAM connector" );

					this.id   = id ;
					this.opts = opts ;
					this.dest = dest ;
				}

				public void run()
				{
					if (_log.shouldLog(Log.DEBUG))
						_log.debug ( "run() called for socket connector " + id );

					try
					{
						try
						{
							I2PSocket i2ps = socketMgr.connect ( dest, opts );

							createSocketHandler ( i2ps, id );

							recv.notifyStreamOutgoingConnection ( id, "OK", null );
						}

						catch ( DataFormatException e )
						{
							if (_log.shouldLog(Log.DEBUG))
								_log.debug ( "Invalid destination in STREAM CONNECT message" );
							recv.notifyStreamOutgoingConnection ( id, "INVALID_KEY", e.getMessage() );
						}
						catch ( ConnectException e )
						{
							if (_log.shouldLog(Log.DEBUG))
								_log.debug("STREAM CONNECT failed", e);
							recv.notifyStreamOutgoingConnection ( id, "CONNECTION_REFUSED", e.getMessage() );
						}
						catch ( NoRouteToHostException e )
						{
							if (_log.shouldLog(Log.DEBUG))
								_log.debug("STREAM CONNECT failed", e);
							recv.notifyStreamOutgoingConnection ( id, "CANT_REACH_PEER", e.getMessage() );
						}
						catch ( InterruptedIOException e )
						{
							if (_log.shouldLog(Log.DEBUG))
								_log.debug("STREAM CONNECT failed", e);
							recv.notifyStreamOutgoingConnection ( id, "TIMEOUT", e.getMessage() );
						}
						catch ( I2PException e )
						{
							if (_log.shouldLog(Log.DEBUG))
								_log.debug("STREAM CONNECT failed", e);
							recv.notifyStreamOutgoingConnection ( id, "I2P_ERROR", e.getMessage() );
						}
					}
					catch ( IOException e )
					{
						if (_log.shouldLog(Log.DEBUG))
							_log.debug ( "Error sending disconnection notice for handler "
						             + id, e );
					}

					if (_log.shouldLog(Log.DEBUG))
						_log.debug ( "Shutting down SAM STREAM session connector " + id );
				}
		}

		/**
		 * Lets us push data through the stream without blocking, (even after exceeding
		 * the I2PSocket's buffer)
		 * 
		 * @param s I2PSocket
		 * @param id Socket ID
		 * @return v2StreamSender
		 * @throws IOException 
		 */
		@Override
		protected StreamSender newStreamSender ( I2PSocket s, int id ) throws IOException
		{
			return new V2StreamSender ( s, id ) ;
		}

		@Override
		protected SAMStreamSessionSocketReader 
				newSAMStreamSessionSocketReader(I2PSocket s, int id ) throws IOException
		{
			return new SAMv2StreamSessionSocketReader(s,id);
		}

		private class V2StreamSender extends StreamSender
		{
				private final List<ByteArray> _data;
				private int _dataSize;
				private final ByteCache _cache;
				private final OutputStream _out;
				private volatile boolean _stillRunning, _shuttingDownGracefully;
				private final Object runningLock = new Object();

				public V2StreamSender ( I2PSocket s, int id ) throws IOException
				{
					super ( s, id );
					_data = new ArrayList<ByteArray> ( 1 );
					_cache = ByteCache.getInstance ( 10, 32 * 1024 );
					_out = s.getOutputStream();
					_stillRunning = true;
				}

				/**
				 * Send bytes through the SAM STREAM session socket sender
				 *
				 * @param in Data stream of data to send
				 * @param size Count of bytes to send
				 * @throws IOException if the client didnt provide enough data
				 */
				@Override
				public void sendBytes ( InputStream in, int size ) throws IOException
				{
					if ( _log.shouldLog ( Log.DEBUG ) )
						_log.debug ( "Handler " + _id + ": sending " + size + " bytes" );

					ByteArray ba = _cache.acquire();

					int read = DataHelper.read ( in, ba.getData(), 0, size );

					if ( read != size )
						throw new IOException ( "Insufficient data from the SAM client (" + read + "/" + size + ")" );

					ba.setValid ( read );

					synchronized ( _data )
					{
						if ( _dataSize >= SOCKET_HANDLER_BUF_SIZE )
						{
							_cache.release ( ba, false );
							recv.streamSendAnswer ( _id, "FAILED", "BUFFER_FULL" ) ;
						}
						else
						{
							_dataSize += size ;
							_data.add ( ba );
							_data.notifyAll();

							if ( _dataSize >= SOCKET_HANDLER_BUF_SIZE )
							{
								recv.streamSendAnswer ( _id, "OK", "BUFFER_FULL" ) ;
							}
							else
							{
								recv.streamSendAnswer ( _id, "OK", "READY" );
							}
						}
					}
				}

				/**
				 * Stop a SAM STREAM session socket sender thread immediately
				 *
				 */
				@Override
				public void stopRunning()
				{
					if (_log.shouldLog(Log.DEBUG))
						_log.debug ( "stopRunning() invoked on socket sender " + _id );

					synchronized ( runningLock )
					{
						if ( _stillRunning )
						{
							_stillRunning = false;

							try
							{
								i2pSocket.close();
							}
							catch ( IOException e )
							{
								if (_log.shouldLog(Log.DEBUG))
									_log.debug ( "Caught IOException", e );
							}

							synchronized ( _data )
							{
								_data.clear();
								_data.notifyAll();
							}
						}
					}
				}

				/**
				 * Stop a SAM STREAM session socket sender gracefully: stop the
				 * sender thread once all pending data has been sent.
				 */
				@Override
				public void shutDownGracefully()
				{
					if (_log.shouldLog(Log.DEBUG))
						_log.debug ( "shutDownGracefully() invoked on socket sender " + _id );
					_shuttingDownGracefully = true;
				}

				@Override
				public void run()
				{
					if (_log.shouldLog(Log.DEBUG))
						_log.debug ( "run() called for socket sender " + _id );
					ByteArray data = null;

					while ( _stillRunning )
					{
						data = null;

						try
						{
							synchronized ( _data )
							{
								if ( !_data.isEmpty() )
								{
									int formerSize = _dataSize ;
									data = _data.remove ( 0 );
									_dataSize -= data.getValid();

									if ( ( formerSize >= SOCKET_HANDLER_BUF_SIZE ) && ( _dataSize < SOCKET_HANDLER_BUF_SIZE ) )
										recv.notifyStreamSendBufferFree ( _id );
								}
								else if ( _shuttingDownGracefully )
								{
									/* No data left and shutting down gracefully?
									If so, stop the sender. */
									stopRunning();
									break;
								}
								else
								{
									/* Wait for data. */
									_data.wait ( 5000 );
								}
							}

							if ( data != null )
							{
								try
								{
									_out.write ( data.getData(), 0, data.getValid() );

									if ( forceFlush )
									{
										// i dont like doing this, but it clears the buffer issues
										_out.flush();
									}
								}
								catch ( IOException ioe )
								{
									// ok, the stream failed, but the SAM client didn't

									if ( _log.shouldLog ( Log.WARN ) )
										_log.warn ( "Stream failed", ioe );

									removeSocketHandler ( _id );

									stopRunning();

								}
								finally
								{
									_cache.release ( data, false );
								}
							}
						}
						catch ( InterruptedException ie ) {}
						catch ( IOException e ) {}}

					synchronized ( _data )
					{
						_data.clear();
					}
				}
		}

		/**
		 * Send bytes through a SAM STREAM session.
		 *
		 * @param id Stream ID
		 * @param limit limitation
		 * @param nolimit true to limit
		 * @return True if the data was queued for sending, false otherwise
		*/
		@Override
		public boolean setReceiveLimit ( int id, long limit, boolean nolimit )
		{
			SAMStreamSessionSocketReader reader = getSocketReader ( id );

			if ( reader == null )
			{
				if ( _log.shouldLog ( Log.WARN ) )
					_log.warn ( "Trying to set a limit to a nonexistent reader " + id );

				return false;
			}

			( (SAMv2StreamSessionSocketReader) reader).setLimit ( limit, nolimit );

			return true;
		}

		/**
		 * SAM STREAM socket reader, running in its own thread.  It forwards
		 * forward data to/from an I2P socket.
		 *
		 * @author human
		*/
		public class SAMv2StreamSessionSocketReader extends SAMv1StreamSessionSocketReader
		{
				protected boolean nolimit       ;
				protected long    limit         ;
				protected long    totalReceived ;

				/**
				 * Create a new SAM STREAM session socket reader
				 *
				 * @param s Socket to be handled
				 * @param id Unique id assigned to the handler
				 */
				public SAMv2StreamSessionSocketReader ( I2PSocket s, int id ) throws IOException
				{
					super ( s, id );
				}

				public void setLimit ( long limit, boolean nolimit )
				{
					synchronized (runningLock)
					{
						this.limit   = limit    ;
						this.nolimit = nolimit  ;
						runningLock.notify() ;
					}
					if (_log.shouldLog(Log.DEBUG))
						_log.debug ( "new limit set for socket reader " + id + " : " + (nolimit ? "NOLIMIT" : limit + " bytes" ) );
				}

			        @Override
				public void run()
				{
					if (_log.shouldLog(Log.DEBUG))
						_log.debug ( "run() called for socket reader " + id );

					int read = -1;
					ByteBuffer data = ByteBuffer.allocate(SOCKET_HANDLER_BUF_SIZE);

					try
					{
						InputStream in = i2pSocket.getInputStream();

						while ( stillRunning )
						{
							synchronized (runningLock)
							{
								while ( stillRunning && ( !nolimit && totalReceived >= limit) )
								{
									try{
										runningLock.wait() ;
									}
									catch (InterruptedException ie)
									{}
								}
								if ( !stillRunning )
									break ;
							}
							
							// not ByteBuffer to avoid Java 8/9 issues
							((Buffer)data).clear();
							read = Channels.newChannel(in).read ( data );

							if ( read == -1 )
							{
								if (_log.shouldLog(Log.DEBUG))
									_log.debug ( "Handler " + id + ": connection closed" );
								break;
							}

							totalReceived += read ;
							// not ByteBuffer to avoid Java 8/9 issues with flip()
							((Buffer)data).flip();
							recv.receiveStreamBytes ( id, data );
						}
					}
					catch ( IOException e )
					{
						if (_log.shouldLog(Log.DEBUG))
							_log.debug ( "Caught IOException", e );
					}

					try
					{
						i2pSocket.close();
					}
					catch ( IOException e )
					{
						if (_log.shouldLog(Log.DEBUG))
							_log.debug ( "Caught IOException", e );
					}

					if ( stillRunning )
					{
						removeSocketHandler ( id );
						// FIXME: we need error reporting here!

						try
						{
							recv.notifyStreamDisconnection ( id, "OK", null );
						}
						catch ( IOException e )
						{
							if (_log.shouldLog(Log.DEBUG))
								_log.debug ( "Error sending disconnection notice for handler "
							             + id, e );
						}
					}

					if (_log.shouldLog(Log.DEBUG))
						_log.debug ( "Shutting down SAM STREAM session socket handler " + id );
				}
		}
}
