//
//  ========================================================================
//  Copyright (c) 1995-2022 Mort Bay Consulting Pty Ltd and others.
//  ------------------------------------------------------------------------
//  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.http2.client;

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import org.eclipse.jetty.http.HttpFields;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.http.HttpURI;
import org.eclipse.jetty.http.HttpVersion;
import org.eclipse.jetty.http.MetaData;
import org.eclipse.jetty.http2.api.Session;
import org.eclipse.jetty.http2.api.Stream;
import org.eclipse.jetty.http2.api.server.ServerSessionListener;
import org.eclipse.jetty.http2.frames.DataFrame;
import org.eclipse.jetty.http2.frames.Frame;
import org.eclipse.jetty.http2.frames.GoAwayFrame;
import org.eclipse.jetty.http2.frames.HeadersFrame;
import org.eclipse.jetty.http2.frames.PushPromiseFrame;
import org.eclipse.jetty.http2.frames.ResetFrame;
import org.eclipse.jetty.http2.server.RawHTTP2ServerConnectionFactory;
import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.eclipse.jetty.util.Callback;
import org.eclipse.jetty.util.FuturePromise;
import org.eclipse.jetty.util.IteratingCallback;
import org.eclipse.jetty.util.Promise;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class RawHTTP2ProxyTest
{
    private static final Logger LOGGER = Log.getLogger(RawHTTP2ProxyTest.class);

    private final List<Server> servers = new ArrayList<>();
    private final List<HTTP2Client> clients = new ArrayList<>();

    private Server startServer(String name, ServerSessionListener listener) throws Exception
    {
        QueuedThreadPool serverExecutor = new QueuedThreadPool();
        serverExecutor.setName(name);
        Server server = new Server(serverExecutor);
        RawHTTP2ServerConnectionFactory connectionFactory = new RawHTTP2ServerConnectionFactory(new HttpConfiguration(), listener);
        ServerConnector connector = new ServerConnector(server, 1, 1, connectionFactory);
        server.addConnector(connector);
        server.setAttribute("connector", connector);
        servers.add(server);
        server.start();
        return server;
    }

    private HTTP2Client startClient(String name) throws Exception
    {
        HTTP2Client client = new HTTP2Client();
        QueuedThreadPool clientExecutor = new QueuedThreadPool();
        clientExecutor.setName(name);
        client.setExecutor(clientExecutor);
        clients.add(client);
        client.start();
        return client;
    }

    @AfterEach
    public void dispose() throws Exception
    {
        for (int i = clients.size() - 1; i >= 0; i--)
        {
            HTTP2Client client = clients.get(i);
            client.stop();
        }
        for (int i = servers.size() - 1; i >= 0; i--)
        {
            Server server = servers.get(i);
            server.stop();
        }
    }

    @Test
    public void testRawHTTP2Proxy() throws Exception
    {
        byte[] data1 = new byte[1024];
        new Random().nextBytes(data1);
        ByteBuffer buffer1 = ByteBuffer.wrap(data1);
        Server server1 = startServer("server1", new ServerSessionListener.Adapter()
        {
            @Override
            public Stream.Listener onNewStream(Stream stream, HeadersFrame frame)
            {
                if (LOGGER.isDebugEnabled())
                    LOGGER.debug("SERVER1 received {}", frame);
                return new Stream.Listener.Adapter()
                {
                    @Override
                    public void onHeaders(Stream stream, HeadersFrame frame)
                    {
                        if (LOGGER.isDebugEnabled())
                            LOGGER.debug("SERVER1 received {}", frame);
                        if (frame.isEndStream())
                        {
                            MetaData.Response response = new MetaData.Response(HttpVersion.HTTP_2, HttpStatus.OK_200, new HttpFields());
                            HeadersFrame reply = new HeadersFrame(stream.getId(), response, null, false);
                            if (LOGGER.isDebugEnabled())
                                LOGGER.debug("SERVER1 sending {}", reply);
                            stream.headers(reply, new Callback()
                            {
                                @Override
                                public void succeeded()
                                {
                                    DataFrame data = new DataFrame(stream.getId(), buffer1.slice(), true);
                                    if (LOGGER.isDebugEnabled())
                                        LOGGER.debug("SERVER1 sending {}", data);
                                    stream.data(data, NOOP);
                                }
                            });
                        }
                    }
                };
            }
        });
        ServerConnector connector1 = (ServerConnector)server1.getAttribute("connector");
        Server server2 = startServer("server2", new ServerSessionListener.Adapter()
        {
            @Override
            public Stream.Listener onNewStream(Stream stream, HeadersFrame frame)
            {
                if (LOGGER.isDebugEnabled())
                    LOGGER.debug("SERVER2 received {}", frame);
                return new Stream.Listener.Adapter()
                {
                    @Override
                    public void onData(Stream stream, DataFrame frame, Callback callback)
                    {
                        if (LOGGER.isDebugEnabled())
                            LOGGER.debug("SERVER2 received {}", frame);
                        callback.succeeded();
                        MetaData.Response response = new MetaData.Response(HttpVersion.HTTP_2, HttpStatus.OK_200, new HttpFields());
                        Callback.Completable completable1 = new Callback.Completable();
                        HeadersFrame reply = new HeadersFrame(stream.getId(), response, null, false);
                        if (LOGGER.isDebugEnabled())
                            LOGGER.debug("SERVER2 sending {}", reply);
                        stream.headers(reply, completable1);
                        completable1.thenCompose(ignored ->
                        {
                            Callback.Completable completable2 = new Callback.Completable();
                            DataFrame data = new DataFrame(stream.getId(), buffer1.slice(), false);
                            if (LOGGER.isDebugEnabled())
                                LOGGER.debug("SERVER2 sending {}", data);
                            stream.data(data, completable2);
                            return completable2;
                        }).thenRun(() ->
                        {
                            MetaData trailer = new MetaData(HttpVersion.HTTP_2, new HttpFields());
                            HeadersFrame end = new HeadersFrame(stream.getId(), trailer, null, true);
                            if (LOGGER.isDebugEnabled())
                                LOGGER.debug("SERVER2 sending {}", end);
                            stream.headers(end, Callback.NOOP);
                        });
                    }
                };
            }
        });
        ServerConnector connector2 = (ServerConnector)server2.getAttribute("connector");
        HTTP2Client proxyClient = startClient("proxyClient");
        Server proxyServer = startServer("proxyServer", new ClientToProxySessionListener(proxyClient));
        ServerConnector proxyConnector = (ServerConnector)proxyServer.getAttribute("connector");
        InetSocketAddress proxyAddress = new InetSocketAddress("localhost", proxyConnector.getLocalPort());
        HTTP2Client client = startClient("client");

        FuturePromise<Session> clientPromise = new FuturePromise<>();
        client.connect(proxyAddress, new Session.Listener.Adapter(), clientPromise);
        Session clientSession = clientPromise.get(5, TimeUnit.SECONDS);

        // Send a request with trailers for server1.
        HttpFields fields1 = new HttpFields();
        fields1.put("X-Target", String.valueOf(connector1.getLocalPort()));
        MetaData.Request request1 = new MetaData.Request("GET", new HttpURI("http://localhost/server1"), HttpVersion.HTTP_2, fields1);
        FuturePromise<Stream> streamPromise1 = new FuturePromise<>();
        CountDownLatch latch1 = new CountDownLatch(1);
        clientSession.newStream(new HeadersFrame(request1, null, false), streamPromise1, new Stream.Listener.Adapter()
        {
            @Override
            public void onHeaders(Stream stream, HeadersFrame frame)
            {
                if (LOGGER.isDebugEnabled())
                    LOGGER.debug("CLIENT received {}", frame);
            }

            @Override
            public void onData(Stream stream, DataFrame frame, Callback callback)
            {
                if (LOGGER.isDebugEnabled())
                    LOGGER.debug("CLIENT received {}", frame);
                assertEquals(buffer1.slice(), frame.getData());
                callback.succeeded();
                latch1.countDown();
            }
        });
        Stream stream1 = streamPromise1.get(5, TimeUnit.SECONDS);
        stream1.headers(new HeadersFrame(stream1.getId(), new MetaData(HttpVersion.HTTP_2, new HttpFields()), null, true), Callback.NOOP);

        // Send a request for server2.
        HttpFields fields2 = new HttpFields();
        fields2.put("X-Target", String.valueOf(connector2.getLocalPort()));
        MetaData.Request request2 = new MetaData.Request("GET", new HttpURI("http://localhost/server1"), HttpVersion.HTTP_2, fields2);
        FuturePromise<Stream> streamPromise2 = new FuturePromise<>();
        CountDownLatch latch2 = new CountDownLatch(1);
        clientSession.newStream(new HeadersFrame(request2, null, false), streamPromise2, new Stream.Listener.Adapter()
        {
            @Override
            public void onHeaders(Stream stream, HeadersFrame frame)
            {
                if (LOGGER.isDebugEnabled())
                    LOGGER.debug("CLIENT received {}", frame);
                if (frame.isEndStream())
                    latch2.countDown();
            }

            @Override
            public void onData(Stream stream, DataFrame frame, Callback callback)
            {
                if (LOGGER.isDebugEnabled())
                    LOGGER.debug("CLIENT received {}", frame);
                callback.succeeded();
            }
        });
        Stream stream2 = streamPromise2.get(5, TimeUnit.SECONDS);
        stream2.data(new DataFrame(stream2.getId(), buffer1.slice(), true), Callback.NOOP);

        assertTrue(latch1.await(5, TimeUnit.SECONDS));
        assertTrue(latch2.await(5, TimeUnit.SECONDS));
    }

    private static class ClientToProxySessionListener extends ServerSessionListener.Adapter
    {
        private final Map<Integer, ClientToProxyToServer> forwarders = new ConcurrentHashMap<>();
        private final HTTP2Client client;

        private ClientToProxySessionListener(HTTP2Client client)
        {
            this.client = client;
        }

        @Override
        public Stream.Listener onNewStream(Stream stream, HeadersFrame frame)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("Received {} for {} on {}: {}", frame, stream, stream.getSession(), frame.getMetaData());
            // Forward to the right server.
            MetaData metaData = frame.getMetaData();
            HttpFields fields = metaData.getFields();
            int port = Integer.parseInt(fields.get("X-Target"));
            ClientToProxyToServer clientToProxyToServer = forwarders.computeIfAbsent(port, p -> new ClientToProxyToServer("localhost", p, client));
            clientToProxyToServer.offer(stream, frame, Callback.NOOP);
            return clientToProxyToServer;
        }

        @Override
        public void onClose(Session session, GoAwayFrame frame)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("Received {} on {}", frame, session);
            // TODO
        }

        @Override
        public boolean onIdleTimeout(Session session)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("Idle timeout on {}", session);
            // TODO
            return true;
        }

        @Override
        public void onFailure(Session session, Throwable failure)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("Failure on " + session, failure);
            // TODO
        }
    }

    private static class ClientToProxyToServer extends IteratingCallback implements Stream.Listener
    {
        private final Object lock = this;
        private final Map<Stream, Deque<FrameInfo>> frames = new HashMap<>();
        private final Map<Stream, Stream> streams = new HashMap<>();
        private final ServerToProxyToClient serverToProxyToClient = new ServerToProxyToClient();
        private final String host;
        private final int port;
        private final HTTP2Client client;
        private Session proxyToServerSession;
        private FrameInfo frameInfo;
        private Stream clientToProxyStream;

        private ClientToProxyToServer(String host, int port, HTTP2Client client)
        {
            this.host = host;
            this.port = port;
            this.client = client;
        }

        private void offer(Stream stream, Frame frame, Callback callback)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("CPS queueing {} for {} on {}", frame, stream, stream.getSession());
            boolean connected;
            synchronized (lock)
            {
                Deque<FrameInfo> deque = frames.computeIfAbsent(stream, s -> new ArrayDeque<>());
                deque.offer(new FrameInfo(frame, callback));
                connected = proxyToServerSession != null;
            }
            if (connected)
                iterate();
            else
                connect();
        }

        private void connect()
        {
            InetSocketAddress address = new InetSocketAddress(host, port);
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("CPS connecting to {}", address);
            client.connect(address, new ServerToProxySessionListener(), new Promise<Session>()
            {
                @Override
                public void succeeded(Session result)
                {
                    if (LOGGER.isDebugEnabled())
                        LOGGER.debug("CPS connected to {} with {}", address, result);
                    synchronized (lock)
                    {
                        proxyToServerSession = result;
                    }
                    iterate();
                }

                @Override
                public void failed(Throwable x)
                {
                    if (LOGGER.isDebugEnabled())
                        LOGGER.debug("CPS connect failed to {}", address);
                    // TODO: drain the queue and fail the streams.
                }
            });
        }

        @Override
        protected Action process() throws Throwable
        {
            Stream proxyToServerStream = null;
            Session proxyToServerSession = null;
            synchronized (lock)
            {
                for (Map.Entry<Stream, Deque<FrameInfo>> entry : frames.entrySet())
                {
                    frameInfo = entry.getValue().poll();
                    if (frameInfo != null)
                    {
                        clientToProxyStream = entry.getKey();
                        proxyToServerStream = streams.get(clientToProxyStream);
                        proxyToServerSession = this.proxyToServerSession;
                        break;
                    }
                }
            }

            if (LOGGER.isDebugEnabled())
                LOGGER.debug("CPS processing {} for {} to {}", frameInfo, clientToProxyStream, proxyToServerStream);

            if (frameInfo == null)
                return Action.IDLE;

            if (proxyToServerStream == null)
            {
                HeadersFrame clientToProxyFrame = (HeadersFrame)frameInfo.frame;
                HeadersFrame proxyToServerFrame = new HeadersFrame(clientToProxyFrame.getMetaData(), clientToProxyFrame.getPriority(), clientToProxyFrame.isEndStream());
                proxyToServerSession.newStream(proxyToServerFrame, new Promise<Stream>()
                {
                    @Override
                    public void succeeded(Stream result)
                    {
                        synchronized (lock)
                        {
                            if (LOGGER.isDebugEnabled())
                                LOGGER.debug("CPS created {}", result);
                            streams.put(clientToProxyStream, result);
                        }
                        serverToProxyToClient.link(result, clientToProxyStream);
                        ClientToProxyToServer.this.succeeded();
                    }

                    @Override
                    public void failed(Throwable failure)
                    {
                        if (LOGGER.isDebugEnabled())
                            LOGGER.debug("CPS create failed", failure);
                        // TODO: cannot open stream to server.
                        ClientToProxyToServer.this.failed(failure);
                    }
                }, serverToProxyToClient);
                return Action.SCHEDULED;
            }
            else
            {
                if (LOGGER.isDebugEnabled())
                    LOGGER.debug("CPS forwarding {} from {} to {}", frameInfo, clientToProxyStream, proxyToServerStream);
                switch (frameInfo.frame.getType())
                {
                    case HEADERS:
                    {
                        HeadersFrame clientToProxyFrame = (HeadersFrame)frameInfo.frame;
                        HeadersFrame proxyToServerFrame = new HeadersFrame(proxyToServerStream.getId(), clientToProxyFrame.getMetaData(), clientToProxyFrame.getPriority(), clientToProxyFrame.isEndStream());
                        proxyToServerStream.headers(proxyToServerFrame, this);
                        return Action.SCHEDULED;
                    }
                    case DATA:
                    {
                        DataFrame clientToProxyFrame = (DataFrame)frameInfo.frame;
                        DataFrame proxyToServerFrame = new DataFrame(proxyToServerStream.getId(), clientToProxyFrame.getData(), clientToProxyFrame.isEndStream());
                        proxyToServerStream.data(proxyToServerFrame, this);
                        return Action.SCHEDULED;
                    }
                    default:
                    {
                        throw new IllegalStateException();
                    }
                }
            }
        }

        @Override
        public void succeeded()
        {
            frameInfo.callback.succeeded();
            super.succeeded();
        }

        @Override
        public void failed(Throwable failure)
        {
            frameInfo.callback.failed(failure);
            super.failed(failure);
        }

        @Override
        public void onHeaders(Stream stream, HeadersFrame frame)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("CPS received {} on {}", frame, stream);
            offer(stream, frame, NOOP);
        }

        @Override
        public Stream.Listener onPush(Stream stream, PushPromiseFrame frame)
        {
            // Clients don't push.
            return null;
        }

        @Override
        public void onData(Stream stream, DataFrame frame, Callback callback)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("CPS received {} on {}", frame, stream);
            offer(stream, frame, callback);
        }

        @Override
        public void onReset(Stream stream, ResetFrame frame)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("CPS received {} on {}", frame, stream);
            // TODO: drain the queue for that stream, and notify server.
        }

        @Override
        public boolean onIdleTimeout(Stream stream, Throwable x)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("CPS idle timeout for {}", stream);
            // TODO: drain the queue for that stream, reset stream, and notify server.
            return true;
        }
    }

    private static class ServerToProxySessionListener extends Session.Listener.Adapter
    {
        @Override
        public void onClose(Session session, GoAwayFrame frame)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("Received {} on {}", frame, session);
            // TODO
        }

        @Override
        public boolean onIdleTimeout(Session session)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("Idle timeout on {}", session);
            // TODO
            return true;
        }

        @Override
        public void onFailure(Session session, Throwable failure)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("Failure on " + session, failure);
            // TODO
        }
    }

    private static class ServerToProxyToClient extends IteratingCallback implements Stream.Listener
    {
        private final Object lock = this;
        private final Map<Stream, Deque<FrameInfo>> frames = new HashMap<>();
        private final Map<Stream, Stream> streams = new HashMap<>();
        private FrameInfo frameInfo;
        private Stream serverToProxyStream;

        @Override
        protected Action process() throws Throwable
        {
            Stream proxyToClientStream = null;
            synchronized (lock)
            {
                for (Map.Entry<Stream, Deque<FrameInfo>> entry : frames.entrySet())
                {
                    frameInfo = entry.getValue().poll();
                    if (frameInfo != null)
                    {
                        serverToProxyStream = entry.getKey();
                        proxyToClientStream = streams.get(serverToProxyStream);
                        break;
                    }
                }
            }

            if (LOGGER.isDebugEnabled())
                LOGGER.debug("SPC processing {} for {} to {}", frameInfo, serverToProxyStream, proxyToClientStream);

            // It may happen that we received a frame from the server,
            // but the proxyToClientStream is not linked yet.
            if (proxyToClientStream == null)
                return Action.IDLE;

            if (LOGGER.isDebugEnabled())
                LOGGER.debug("SPC forwarding {} for {} to {}", frameInfo, serverToProxyStream, proxyToClientStream);

            switch (frameInfo.frame.getType())
            {
                case HEADERS:
                {
                    HeadersFrame serverToProxyFrame = (HeadersFrame)frameInfo.frame;
                    HeadersFrame proxyToClientFrame = new HeadersFrame(proxyToClientStream.getId(), serverToProxyFrame.getMetaData(), serverToProxyFrame.getPriority(), serverToProxyFrame.isEndStream());
                    proxyToClientStream.headers(proxyToClientFrame, this);
                    return Action.SCHEDULED;
                }
                case DATA:
                {
                    DataFrame clientToProxyFrame = (DataFrame)frameInfo.frame;
                    DataFrame proxyToServerFrame = new DataFrame(serverToProxyStream.getId(), clientToProxyFrame.getData(), clientToProxyFrame.isEndStream());
                    proxyToClientStream.data(proxyToServerFrame, this);
                    return Action.SCHEDULED;
                }
                case PUSH_PROMISE:
                {
                    // TODO
                    throw new UnsupportedOperationException();
                }
                default:
                {
                    throw new IllegalStateException();
                }
            }
        }

        @Override
        public void succeeded()
        {
            frameInfo.callback.succeeded();
            super.succeeded();
        }

        @Override
        public void failed(Throwable failure)
        {
            frameInfo.callback.failed(failure);
            super.failed(failure);
        }

        private void offer(Stream stream, Frame frame, Callback callback)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("SPC queueing {} for {} on {}", frame, stream, stream.getSession());
            synchronized (lock)
            {
                Deque<FrameInfo> deque = frames.computeIfAbsent(stream, s -> new ArrayDeque<>());
                deque.offer(new FrameInfo(frame, callback));
            }
            iterate();
        }

        @Override
        public void onHeaders(Stream stream, HeadersFrame frame)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("SPC received {} on {}", frame, stream);
            offer(stream, frame, NOOP);
        }

        @Override
        public Stream.Listener onPush(Stream stream, PushPromiseFrame frame)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("SPC received {} on {}", frame, stream);
            // TODO
            return null;
        }

        @Override
        public void onData(Stream stream, DataFrame frame, Callback callback)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("SPC received {} on {}", frame, stream);
            offer(stream, frame, callback);
        }

        @Override
        public void onReset(Stream stream, ResetFrame frame)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("SPC received {} on {}", frame, stream);
            // TODO: drain queue, reset client stream.
        }

        @Override
        public boolean onIdleTimeout(Stream stream, Throwable x)
        {
            if (LOGGER.isDebugEnabled())
                LOGGER.debug("SPC idle timeout for {}", stream);
            // TODO:
            return false;
        }

        private void link(Stream proxyToServerStream, Stream clientToProxyStream)
        {
            synchronized (lock)
            {
                streams.put(proxyToServerStream, clientToProxyStream);
            }
            iterate();
        }
    }

    private static class FrameInfo
    {
        private final Frame frame;
        private final Callback callback;

        private FrameInfo(Frame frame, Callback callback)
        {
            this.frame = frame;
            this.callback = callback;
        }

        @Override
        public String toString()
        {
            return frame.toString();
        }
    }
}
