//
//  ========================================================================
//  Copyright (c) 1995-2016 Mort Bay Consulting Pty. Ltd.
//  ------------------------------------------------------------------------
//  All rights reserved. This program and the accompanying materials
//  are made available under the terms of the Eclipse Public License v1.0
//  and Apache License v2.0 which accompanies this distribution.
//
//      The Eclipse Public License is available at
//      http://www.eclipse.org/legal/epl-v10.html
//
//      The Apache License v2.0 is available at
//      http://www.opensource.org/licenses/apache2.0.php
//
//  You may elect to redistribute this code under either of these licenses.
//  ========================================================================
//

package org.eclipse.jetty.test;

import static org.hamcrest.Matchers.startsWith;
import static org.junit.Assert.assertThat;

import java.io.IOException;
import java.io.OutputStream;
import java.net.Inet4Address;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import javax.net.ssl.SSLSocket;
import javax.servlet.AsyncContext;
import javax.servlet.ReadListener;
import javax.servlet.ServletException;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.eclipse.jetty.http.HttpVersion;
import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory;
import org.eclipse.jetty.server.Connector;
import org.eclipse.jetty.server.HttpChannelState;
import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.server.HttpConnectionFactory;
import org.eclipse.jetty.server.LocalConnector;
import org.eclipse.jetty.server.LocalConnector.LocalEndPoint;
import org.eclipse.jetty.server.NetworkConnector;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.SecureRequestCustomizer;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.server.SslConnectionFactory;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.IO;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.hamcrest.Matchers;
import org.junit.AfterClass;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;

@RunWith(Parameterized.class)
public class HttpInputIntegrationTest
{
    
    enum Mode { BLOCKING, ASYNC_DISPATCHED, ASYNC_OTHER_DISPATCHED, ASYNC_OTHER_WAIT }
    public final static String EOF = "__EOF__";
    public final static String DELAY = "__DELAY__";
    public final static String ABORT = "__ABORT__";
    
    private static Server __server;
    private static HttpConfiguration __config;
    private static HttpConfiguration __sslConfig;
    private static SslContextFactory __sslContextFactory;
    
    @BeforeClass
    public static void beforeClass() throws Exception
    {
        __config = new HttpConfiguration();
        
        __server = new Server();
        LocalConnector local=new LocalConnector(__server,new HttpConnectionFactory(__config));
        local.setIdleTimeout(4000);
        __server.addConnector(local);
        
        ServerConnector http = new ServerConnector(__server,new HttpConnectionFactory(__config),new HTTP2CServerConnectionFactory(__config));
        http.setIdleTimeout(4000);
        __server.addConnector(http);
        

        // SSL Context Factory for HTTPS and HTTP/2
        String jetty_distro = System.getProperty("jetty.distro","../../jetty-distribution/target/distribution");
        __sslContextFactory = new SslContextFactory();
        __sslContextFactory.setKeyStorePath(jetty_distro + "/../../../jetty-server/src/test/config/etc/keystore");
        __sslContextFactory.setKeyStorePassword("OBF:1vny1zlo1x8e1vnw1vn61x8g1zlu1vn4");
        __sslContextFactory.setKeyManagerPassword("OBF:1u2u1wml1z7s1z7a1wnl1u2g");

        // HTTPS Configuration
        __sslConfig = new HttpConfiguration(__config);
        __sslConfig.addCustomizer(new SecureRequestCustomizer());

        // HTTP/1 Connection Factory
        HttpConnectionFactory h1=new HttpConnectionFactory(__sslConfig);
        /* TODO
        // HTTP/2 Connection Factory
        HTTP2ServerConnectionFactory h2 = new HTTP2ServerConnectionFactory(__sslConfig);
        
        NegotiatingServerConnectionFactory.checkProtocolNegotiationAvailable();
        ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory();
        alpn.setDefaultProtocol(h1.getProtocol());
        */
        
        // SSL Connection Factory
        SslConnectionFactory ssl = new SslConnectionFactory(__sslContextFactory,h1.getProtocol() /*TODO alpn.getProtocol()*/);
        
        // HTTP/2 Connector
        ServerConnector http2 = new ServerConnector(__server,ssl,/*TODO alpn,h2,*/ h1);
        http2.setIdleTimeout(4000);
        __server.addConnector(http2);
        
        
        ServletContextHandler context = new ServletContextHandler(__server,"/ctx");
        ServletHolder holder = new ServletHolder(new TestServlet());
        holder.setAsyncSupported(true);
        context.addServlet(holder,"/*");
        
        
        
        __server.start();
    }
    
    @AfterClass
    public static void afterClass() throws Exception
    {
        __server.stop();
    }
    
    interface TestClient
    {        
        /* ------------------------------------------------------------ */
        /**
         * @param uri The URI to test, typically /ctx/test?mode=THE_MODE
         * @param delayMs the delay in MS to use.
         * @param delayInFrame If null, send the request with no delays, if FALSE then send with delays between frames, if TRUE send with delays within frames
         * @param contentLength The content length header to send.
         * @param content The content to send, with each string to be converted to a chunk or a frame 
         * @return The response received in HTTP/1 format
         * @throws Exception
         */
        String send(String uri,int delayMs, Boolean delayInFrame, int contentLength, List<String> content) throws Exception; 
    }

    @Parameterized.Parameters
    public static Collection<Object[]> data()
    {
        List<Object[]> tests = new ArrayList<>();
        

        // TODO other client types!
        // test with the following clients/protocols:
        //   + Local
        //   + HTTP/1
        //   + SSL + HTTP/1
        //   + HTTP/2
        //   + SSL + HTTP/2
        //   + FASTCGI
        for (Class<? extends TestClient> client : new Class[]{LocalClient.class,H1Client.class,H1SClient.class})
        {

            // test async actions that are run:
            //   + By a thread in a container callback
            //   + By another thread while a container callback is active
            //   + By another thread while no container callback is active
            for (Mode mode: Mode.values())
            {

                // test servlet dispatch with:
                //   + Delayed dispatch on
                //   + Delayed dispatch off
                for (Boolean dispatch : new Boolean[]{false,true})
                {
                    // test send with 
                    //   + No delays between frames
                    //   + Delays between frames
                    //   + Delays within frames!
                    for (Boolean delayWithinFrame : new Boolean[]{null,false,true})
                    {
                        // test content 
                        // + unknown length + EOF
                        // + unknown length + content + EOF
                        // + unknown length + content + content + EOF
                        
                        // + known length + EOF
                        // + known length + content + EOF
                        // + known length + content + content + EOF
                        
                        tests.add(new Object[]{tests.size(),client,mode,dispatch,delayWithinFrame,200,0,-1,new String[]{}});
                        tests.add(new Object[]{tests.size(),client,mode,dispatch,delayWithinFrame,200,8,-1,new String[]{"content0"}});
                        tests.add(new Object[]{tests.size(),client,mode,dispatch,delayWithinFrame,200,16,-1,new String[]{"content0","CONTENT1"}});
                        
                        tests.add(new Object[]{tests.size(),client,mode,dispatch,delayWithinFrame,200,0,0,new String[]{}});
                        tests.add(new Object[]{tests.size(),client,mode,dispatch,delayWithinFrame,200,8,8,new String[]{"content0"}});
                        tests.add(new Object[]{tests.size(),client,mode,dispatch,delayWithinFrame,200,16,16,new String[]{"content0","CONTENT1"}});
                        
                    }
                }
            }
        }
        return tests;
    }
    

    final int _id;
    final Class<? extends TestClient> _client;
    final Mode _mode;
    final Boolean _delay;
    final int _status;
    final int _read;
    final int _length;
    final List<String> _send;
    
    public HttpInputIntegrationTest(int id,Class<? extends TestClient> client, Mode mode,boolean dispatch,Boolean delay,int status,int read,int length,String... send)
    {
        _id=id;
        _client=client;
        _mode=mode;
        __config.setDelayDispatchUntilContent(dispatch);
        _delay=delay;
        _status=status;
        _read=read;
        _length=length;
        _send = Arrays.asList(send);
    }
    
    
    private static void runmode(Mode mode,final Request request, final Runnable test)
    {
        switch(mode)
        {
            case ASYNC_DISPATCHED:
            {
                test.run();
                break;
            }   
            case ASYNC_OTHER_DISPATCHED:
            {
                final CountDownLatch latch = new CountDownLatch(1);
                new Thread()
                {
                    @Override
                    public void run()
                    {
                        try
                        {
                            test.run();
                        }
                        finally
                        {
                            latch.countDown();
                        }
                    }
                }.start();
                // prevent caller returning until other thread complete
                try
                {
                    if (!latch.await(5,TimeUnit.SECONDS))
                        Assert.fail();
                }
                catch(Exception e)
                {
                    Assert.fail();
                }
                break;
            }   
            case ASYNC_OTHER_WAIT:
            {
                final CountDownLatch latch = new CountDownLatch(1);
                final HttpChannelState.State S=request.getHttpChannelState().getState();
                new Thread()
                {
                    @Override
                    public void run()
                    {
                        try
                        {
                            if (!latch.await(5,TimeUnit.SECONDS))
                                Assert.fail();
                            
                            // Spin until state change
                            HttpChannelState.State s=request.getHttpChannelState().getState();
                            while(request.getHttpChannelState().getState()==S )
                            {
                                Thread.yield();
                                s=request.getHttpChannelState().getState();
                            }
                            test.run();
                        }
                        catch (Exception e)
                        {
                            e.printStackTrace();
                        }
                    }
                }.start();
                // ensure other thread running before trying to return
                latch.countDown();
                break;
            }
                
            default:
                throw new IllegalStateException();
        }
        
    }
    
    @Test
    public void testOne() throws Exception
    {
        System.err.printf("[%d] TEST   c=%s, m=%s, delayDispatch=%b delayInFrame=%s content-length:%d expect=%d read=%d content:%s%n",_id,_client.getSimpleName(),_mode,__config.isDelayDispatchUntilContent(),_delay,_length,_status,_read,_send);

        TestClient client=_client.newInstance();
        String response = client.send("/ctx/test?mode="+_mode,50,_delay,_length,_send);
        
        int sum=0;
        for (String s:_send)
            for (char c : s.toCharArray())
                sum+=c;
        
        assertThat(response,startsWith("HTTP"));
        assertThat(response,Matchers.containsString(" "+_status+" "));
        assertThat(response,Matchers.containsString("read="+_read));
        assertThat(response,Matchers.containsString("sum="+sum));
    }
    
    @Test
    public void testStress() throws Exception
    {
        System.err.printf("[%d] STRESS c=%s, m=%s, delayDispatch=%b delayInFrame=%s content-length:%d expect=%d read=%d content:%s%n",_id,_client.getSimpleName(),_mode,__config.isDelayDispatchUntilContent(),_delay,_length,_status,_read,_send);

        int sum=0;
        for (String s:_send)
            for (char c : s.toCharArray())
                sum+=c;
        final int summation=sum;
        
        
        final int threads=10;
        final int loops=10;
        
        final AtomicInteger count = new AtomicInteger(0);
        Thread[] t = new Thread[threads];
        
        Runnable run = new Runnable()
        {
            @Override 
            public void run()
            {
                try
                {
                    TestClient client=_client.newInstance();
                    for (int j=0;j<loops;j++)
                    {
                        String response = client.send("/ctx/test?mode="+_mode,10,_delay,_length,_send);
                        assertThat(response,startsWith("HTTP"));
                        assertThat(response,Matchers.containsString(" "+_status+" "));
                        assertThat(response,Matchers.containsString("read="+_read));
                        assertThat(response,Matchers.containsString("sum="+summation));
                        count.incrementAndGet();
                    }
                }
                catch(Exception e)
                {
                    e.printStackTrace();
                }
            }
        };
        
        for (int i=0;i<threads;i++)
        {
            t[i]=new Thread(run);
            t[i].start();
        }
        for (int i=0;i<threads;i++)
            t[i].join();
        
        assertThat(count.get(),Matchers.is(threads*loops));
    }
    
    
    public static class TestServlet extends HttpServlet
    {
        String expected ="content0CONTENT1";
        
        @Override
        protected void doGet(final HttpServletRequest req, final HttpServletResponse resp) throws ServletException, IOException
        {
            final Mode mode = Mode.valueOf(req.getParameter("mode"));
            resp.setContentType("text/plain");
                        
            if (mode==Mode.BLOCKING)
            {
                try
                {
                    String content = IO.toString(req.getInputStream());
                    resp.setStatus(200);
                    resp.setContentType("text/plain");
                    resp.getWriter().println("read="+content.length());
                    int sum = 0;
                    for (char c:content.toCharArray())
                        sum+=c;
                    resp.getWriter().println("sum="+sum);
                }
                catch(Exception e)
                {
                    e.printStackTrace();
                    resp.setStatus(500);
                    resp.getWriter().println("read="+e);
                    resp.getWriter().println("sum=-1");
                }
            }
            else
            {
                // we are asynchronous
                final AsyncContext context = req.startAsync();
                context.setTimeout(10000);
                final ServletInputStream in = req.getInputStream();
                final Request request = Request.getBaseRequest(req);
                final AtomicInteger read = new AtomicInteger(0);
                final AtomicInteger sum = new AtomicInteger(0);

                runmode(mode,request,new Runnable()
                {
                    @Override
                    public void run()
                    {
                        in.setReadListener(new ReadListener()
                        {
                            @Override
                            public void onError(Throwable t)
                            {
                                t.printStackTrace();
                                try
                                {
                                    resp.sendError(500);
                                }
                                catch (IOException e)
                                {
                                    e.printStackTrace();
                                    throw new RuntimeException(e);
                                }
                                context.complete();
                            }

                            @Override
                            public void onDataAvailable() throws IOException
                            {
                                runmode(mode,request,new Runnable()
                                {
                                    @Override
                                    public void run()
                                    {
                                        while(in.isReady() && !in.isFinished())
                                        {
                                            try
                                            {
                                                int b = in.read();
                                                if (b<0)
                                                    return;
                                                sum.addAndGet(b);
                                                int i=read.getAndIncrement();
                                                if (b!=expected.charAt(i))
                                                {
                                                    System.err.printf("XXX '%c'!='%c' at %d%n",expected.charAt(i),(char)b,i);
                                                    System.err.println("    "+request.getHttpChannel());
                                                    System.err.println("    "+request.getHttpChannel().getHttpTransport());
                                                }
                                                
                                            }
                                            catch (IOException e)
                                            {
                                                onError(e);
                                            }
                                        }
                                    }
                                });
                            }

                            @Override
                            public void onAllDataRead() throws IOException
                            {
                                resp.setStatus(200);
                                resp.setContentType("text/plain");
                                resp.getWriter().println("read="+read.get());
                                resp.getWriter().println("sum="+sum.get());
                                context.complete();
                            }
                        });
                    }
                });
            }
        }
    }
    

    public static class LocalClient implements TestClient
    {
        StringBuilder flushed = new StringBuilder();
        @Override
        public String send(String uri,int delayMs, Boolean delayInFrame,int contentLength, List<String> content) throws Exception
        {
            LocalConnector connector = __server.getBean(LocalConnector.class);

            StringBuilder buffer = new StringBuilder();
            buffer.append("GET ").append(uri).append(" HTTP/1.1\r\n");
            buffer.append("Host: localhost\r\n");
            buffer.append("Connection: close\r\n");

            LocalEndPoint local = connector.executeRequest("");
            
            flush(local,buffer,delayMs,delayInFrame,true);

            boolean chunked=contentLength<0;
            if (chunked)
                buffer.append("Transfer-Encoding: chunked\r\n");
            else
                buffer.append("Content-Length: ").append(contentLength).append("\r\n");

            if (contentLength>0)
                buffer.append("Content-Type: text/plain\r\n");
            buffer.append("\r\n");

            flush(local,buffer,delayMs,delayInFrame,false);

            for (String c : content)
            {
                if (chunked)
                {
                    buffer.append(Integer.toHexString(c.length())).append("\r\n");
                    flush(local,buffer,delayMs,delayInFrame,true);
                }

                buffer.append(c.substring(0,1));
                flush(local,buffer,delayMs,delayInFrame,true);
                buffer.append(c.substring(1));
                if (chunked)
                    buffer.append("\r\n"); 
                flush(local,buffer,delayMs,delayInFrame,false);
            }

            if (chunked)
            {
                buffer.append("0");
                flush(local,buffer,delayMs,delayInFrame,true);
                buffer.append("\r\n\r\n");
            }

            flush(local,buffer);
            local.waitUntilClosed();
            return local.takeOutputString();
        }
          

        private void flush(LocalEndPoint local, StringBuilder buffer,int delayMs, Boolean delayInFrame, boolean inFrame) throws Exception
        {
            // Flush now if we should delay
            if (delayInFrame!=null && delayInFrame.equals(inFrame))
            {
                flush(local,buffer);
                Thread.sleep(delayMs);
            }
        }
        
        private void flush(final LocalEndPoint local, StringBuilder buffer) throws Exception
        {
            final String flush=buffer.toString();
            buffer.setLength(0);
            flushed.append(flush);
            local.addInputAndExecute(BufferUtil.toBuffer(flush));
        }

    }

    public static class H1Client implements TestClient
    {
        NetworkConnector _connector;

        public H1Client()
        {
            for (Connector c:__server.getConnectors())
            {
                if (c instanceof NetworkConnector && c.getDefaultConnectionFactory().getProtocol().equals(HttpVersion.HTTP_1_1.asString()))
                {
                    _connector=(NetworkConnector)c;
                    break;
                }
            }
        }

        @Override
        public String send(String uri, int delayMs, Boolean delayInFrame,int contentLength, List<String> content) throws Exception
        {
            int port=_connector.getLocalPort();
            
            try (Socket client = newSocket("localhost", port))
            {
                client.setSoTimeout(5000);
                client.setTcpNoDelay(true);
                client.setSoLinger(true,1);
                OutputStream out = client.getOutputStream();

                StringBuilder buffer = new StringBuilder();
                buffer.append("GET ").append(uri).append(" HTTP/1.1\r\n");
                buffer.append("Host: localhost:").append(port).append("\r\n");
                buffer.append("Connection: close\r\n");

                flush(out,buffer,delayMs,delayInFrame,true);

                boolean chunked=contentLength<0;
                if (chunked)
                    buffer.append("Transfer-Encoding: chunked\r\n");
                else
                    buffer.append("Content-Length: ").append(contentLength).append("\r\n");
                    
                if (contentLength>0)
                    buffer.append("Content-Type: text/plain\r\n");
                buffer.append("\r\n");

                flush(out,buffer,delayMs,delayInFrame,false);
                
                for (String c : content)
                {
                    if (chunked)
                    {
                        buffer.append(Integer.toHexString(c.length())).append("\r\n");
                       flush(out,buffer,delayMs,delayInFrame,true);
                    }
                    
                    buffer.append(c.substring(0,1));
                    flush(out,buffer,delayMs,delayInFrame,true);
                    buffer.append(c.substring(1));
                    flush(out,buffer,delayMs,delayInFrame,false);
                    if (chunked)
                        buffer.append("\r\n"); 
                }

                if (chunked)
                {
                    buffer.append("0");
                    flush(out,buffer,delayMs,delayInFrame,true);
                    buffer.append("\r\n\r\n");
                }
                
                flush(out,buffer);
                
                return IO.toString(client.getInputStream());
            }
            
        }

        private void flush(OutputStream out, StringBuilder buffer, int delayMs, Boolean delayInFrame, boolean inFrame) throws Exception
        {
            // Flush now if we should delay
            if (delayInFrame!=null && delayInFrame.equals(inFrame))
            {
                flush(out,buffer);
                Thread.sleep(delayMs);
            }
        }
        
        private void flush(OutputStream out, StringBuilder buffer) throws Exception
        {
            String flush=buffer.toString();
            buffer.setLength(0);
            out.write(flush.getBytes(StandardCharsets.ISO_8859_1));
            out.flush();
        }

        public Socket newSocket(String host, int port) throws IOException
        {
            return new Socket(host, port);
        }
    }
    
    public static class H1SClient extends H1Client
    {
        public H1SClient()
        {
            for (Connector c:__server.getConnectors())
            {
                if (c instanceof NetworkConnector && c.getDefaultConnectionFactory().getProtocol().equals("SSL"))
                {
                    _connector=(NetworkConnector)c;
                    break;
                }
            }
        }

        public Socket newSocket(String host, int port) throws IOException
        {
            SSLSocket socket = __sslContextFactory.newSslSocket();
            socket.connect(new InetSocketAddress(Inet4Address.getByName(host),port));
            return socket;
        }
    }

    
}
