//
// ========================================================================
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
//
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
// ========================================================================
//

package org.eclipse.jetty.test.client.transport;

import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.IntStream;

import org.eclipse.jetty.client.BytesRequestContent;
import org.eclipse.jetty.client.CompletableResponseListener;
import org.eclipse.jetty.client.Connection;
import org.eclipse.jetty.client.ContentResponse;
import org.eclipse.jetty.client.Destination;
import org.eclipse.jetty.client.InputStreamResponseListener;
import org.eclipse.jetty.client.Origin;
import org.eclipse.jetty.client.Response;
import org.eclipse.jetty.client.Result;
import org.eclipse.jetty.client.StringRequestContent;
import org.eclipse.jetty.http.HttpFields;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.http.HttpURI;
import org.eclipse.jetty.http2.FlowControlStrategy;
import org.eclipse.jetty.io.Content;
import org.eclipse.jetty.io.EndPoint;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.NetworkConnector;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.SecureRequestCustomizer;
import org.eclipse.jetty.toolchain.test.Net;
import org.eclipse.jetty.util.Blocker;
import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.Callback;
import org.eclipse.jetty.util.IO;
import org.eclipse.jetty.util.IteratingCallback;
import org.eclipse.jetty.util.IteratingNestedCallback;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

public class HttpClientTest extends AbstractTest
{
    @ParameterizedTest
    @MethodSource("transports")
    public void testClientUseContentSourceInSpawnedThreadEmptyResponseContent(Transport transport) throws Exception
    {
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                response.write(true, BufferUtil.EMPTY_BUFFER, callback);
                return true;
            }
        });

        var listener = new OnContentSourceListener();

        client.newRequest(newURI(transport))
            .method("POST")
            .send(listener);

        OnContentSourceListener.ClientResponseContent clientResponseContent = listener.clientResponseContent.get(5, TimeUnit.SECONDS);
        assertThat(clientResponseContent.body(), is(""));
        assertThat(clientResponseContent.status(), is(200));
        assertThat(clientResponseContent.trailers(), nullValue());
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testClientUseContentSourceInSpawnedThreadWithResponseContent(Transport transport) throws Exception
    {
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                response.write(true, BufferUtil.toBuffer("some response content", StandardCharsets.UTF_8), callback);
                return true;
            }
        });

        var listener = new OnContentSourceListener();

        client.newRequest(newURI(transport))
            .method("POST")
            .send(listener);

        OnContentSourceListener.ClientResponseContent clientResponseContent = listener.clientResponseContent.get(5, TimeUnit.SECONDS);
        assertThat(clientResponseContent.body(), is("some response content"));
        assertThat(clientResponseContent.status(), is(200));
        assertThat(clientResponseContent.trailers(), nullValue());
    }

    @ParameterizedTest
    @MethodSource("transportsNoFCGI")
    public void testClientUseContentSourceInSpawnedThreadWithTrailer(Transport transport) throws Exception
    {
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback) throws IOException
            {
                response.setTrailersSupplier(() -> HttpFields.build().add("X-Trailer-test", "foobar"));
                // start chunked mode
                try (Blocker.Callback blocker = Blocker.callback())
                {
                    response.write(false, BufferUtil.EMPTY_BUFFER, blocker);
                    blocker.block();
                }

                response.write(true, BufferUtil.toBuffer("some response content", StandardCharsets.UTF_8), callback);
                return true;
            }
        });

        var listener = new OnContentSourceListener();

        client.newRequest(newURI(transport))
            .method("POST")
            .send(listener);

        OnContentSourceListener.ClientResponseContent clientResponseContent = listener.clientResponseContent.get(5, TimeUnit.SECONDS);
        assertThat(clientResponseContent.body(), is("some response content"));
        assertThat(clientResponseContent.status(), is(200));
        assertThat(clientResponseContent.trailers().get("X-Trailer-test"), is("foobar"));
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testRequestWithoutResponseContent(Transport transport) throws Exception
    {
        final int status = HttpStatus.NO_CONTENT_204;
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                response.setStatus(status);
                callback.succeeded();
                return true;
            }
        });

        ContentResponse response = client.newRequest(newURI(transport))
            .timeout(5, TimeUnit.SECONDS)
            .send();

        assertEquals(status, response.getStatus());
        assertEquals(0, response.getContent().length);
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testRequestWithSmallResponseContent(Transport transport) throws Exception
    {
        testRequestWithResponseContent(transport, 1024);
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testRequestWithLargeResponseContent(Transport transport) throws Exception
    {
        testRequestWithResponseContent(transport, 1024 * 1024);
    }

    private void testRequestWithResponseContent(Transport transport, int length) throws Exception
    {
        final byte[] bytes = new byte[length];
        new Random().nextBytes(bytes);
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                response.getHeaders().put(HttpHeader.CONTENT_LENGTH, length);
                response.write(true, ByteBuffer.wrap(bytes), callback);
                return true;
            }
        });

        var request = client.newRequest(newURI(transport))
            .timeout(10, TimeUnit.SECONDS);
        CompletableFuture<ContentResponse> completable = new CompletableResponseListener(request, length).send();
        ContentResponse response = completable.get();

        assertEquals(200, response.getStatus());
        assertArrayEquals(bytes, response.getContent());
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testRequestWithSmallResponseContentChunked(Transport transport) throws Exception
    {
        testRequestWithResponseContentChunked(transport, 512);
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testRequestWithLargeResponseContentChunked(Transport transport) throws Exception
    {
        testRequestWithResponseContentChunked(transport, 512 * 512);
    }

    private void testRequestWithResponseContentChunked(Transport transport, int length) throws Exception
    {
        final byte[] chunk1 = new byte[length];
        final byte[] chunk2 = new byte[length];
        Random random = new Random();
        random.nextBytes(chunk1);
        random.nextBytes(chunk2);
        byte[] bytes = new byte[chunk1.length + chunk2.length];
        System.arraycopy(chunk1, 0, bytes, 0, chunk1.length);
        System.arraycopy(chunk2, 0, bytes, chunk1.length, chunk2.length);
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback) throws Exception
            {
                Content.Sink.write(response, false, ByteBuffer.wrap(chunk1));
                response.write(true, ByteBuffer.wrap(chunk2), callback);
                return true;
            }
        });

        var request = client.newRequest(newURI(transport))
            .timeout(10, TimeUnit.SECONDS);
        CompletableFuture<ContentResponse> completable = new CompletableResponseListener(request, 2 * length).send();
        ContentResponse response = completable.get();

        assertEquals(200, response.getStatus());
        assertArrayEquals(bytes, response.getContent());
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testUploadZeroLengthWithoutResponseContent(Transport transport) throws Exception
    {
        testUploadWithoutResponseContent(transport, 0);
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testUploadSmallWithoutResponseContent(Transport transport) throws Exception
    {
        testUploadWithoutResponseContent(transport, 1024);
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testUploadLargeWithoutResponseContent(Transport transport) throws Exception
    {
        testUploadWithoutResponseContent(transport, 1024 * 1024);
    }

    private void testUploadWithoutResponseContent(Transport transport, int length) throws Exception
    {
        final byte[] bytes = new byte[length];
        new Random().nextBytes(bytes);
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback) throws Exception
            {
                InputStream input = Request.asInputStream(request);
                for (byte b : bytes)
                {
                    Assertions.assertEquals(b & 0xFF, input.read());
                }
                Assertions.assertEquals(-1, input.read());
                callback.succeeded();
                return true;
            }
        });

        ContentResponse response = client.newRequest(newURI(transport))
            .method(HttpMethod.POST)
            .body(new BytesRequestContent(bytes))
            .timeout(15, TimeUnit.SECONDS)
            .send();

        assertEquals(200, response.getStatus());
        assertEquals(0, response.getContent().length);
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testClientManyWritesSlowServer(Transport transport) throws Exception
    {
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback) throws Exception
            {
                long sleep = 1024;
                long total = 0;
                while (true)
                {
                    Content.Chunk chunk = request.read();
                    if (chunk == null)
                    {
                        try (Blocker.Runnable blocker = Blocker.runnable())
                        {
                            request.demand(blocker);
                            blocker.block();
                            continue;
                        }
                    }
                    if (Content.Chunk.isFailure(chunk))
                        throw IO.rethrow(chunk.getFailure());

                    total += chunk.remaining();
                    if (total >= sleep)
                    {
                        sleep(250);
                        sleep += 256;
                    }
                    chunk.release();
                    if (chunk.isLast())
                        break;
                }
                Content.Sink.write(response, true, String.valueOf(total), callback);
                return true;
            }
        });

        int chunks = 256;
        int chunkSize = 16;
        byte[][] bytes = IntStream.range(0, chunks).mapToObj(x -> new byte[chunkSize]).toArray(byte[][]::new);
        BytesRequestContent content = new BytesRequestContent("application/octet-stream", bytes);
        ContentResponse response = client.newRequest(newURI(transport))
            .method(HttpMethod.POST)
            .body(content)
            .timeout(15, TimeUnit.SECONDS)
            .send();

        assertEquals(HttpStatus.OK_200, response.getStatus());
        assertEquals(chunks * chunkSize, Integer.parseInt(response.getContentAsString()));
    }

    @ParameterizedTest
    @MethodSource("transports")
    @Tag("DisableLeakTracking:client:H3")
    @Tag("DisableLeakTracking:client:FCGI")
    public void testRequestAfterFailedRequest(Transport transport) throws Exception
    {
        int length = FlowControlStrategy.DEFAULT_WINDOW_SIZE;
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                response.write(true, ByteBuffer.allocate(length), callback);
                return true;
            }
        });

        // Make a request with a large enough response buffer.
        var request = client.newRequest(newURI(transport));
        CompletableFuture<ContentResponse> completable = new CompletableResponseListener(request, length).send();
        ContentResponse response = completable.get(15, TimeUnit.SECONDS);
        assertEquals(response.getStatus(), 200);

        // Make a request with a small response buffer, should fail.
        try
        {
            request = client.newRequest(newURI(transport));
            completable = new CompletableResponseListener(request, length / 10).send();
            completable.get(15, TimeUnit.SECONDS);
            fail("Expected ExecutionException");
        }
        catch (ExecutionException x)
        {
            assertThat(x.getMessage(), containsString("exceeded"));
        }

        // Verify that we can make another request.
        request = client.newRequest(newURI(transport));
        completable = new CompletableResponseListener(request, length).send();
        response = completable.get(15, TimeUnit.SECONDS);
        assertEquals(response.getStatus(), 200);
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testClientCannotValidateServerCertificate(Transport transport) throws Exception
    {
        // Only run this test for transports over TLS.
        assumeTrue(transport.isSecure());

        start(transport, new EmptyServerHandler());
        // Disable validations on the server to be sure
        // that the test failure happens during the
        // validation of the certificate on the client.
        httpConfig.getCustomizer(SecureRequestCustomizer.class).setSniHostCheck(false);

        // Use a SslContextFactory.Client that verifies server certificates,
        // requests should fail because the server certificate is unknown.
        SslContextFactory.Client clientTLS = new SslContextFactory.Client();
        clientTLS.setEndpointIdentificationAlgorithm("HTTPS");
        client.stop();
        client.setSslContextFactory(clientTLS);
        client.start();

        // H3 times out b/c it is QUIC's way of figuring out a connection cannot be established.
        Class<? extends Exception> expectedType = transport == Transport.H3 ? TimeoutException.class : ExecutionException.class;
        assertThrows(expectedType, () ->
        {
            // Use an IP address not present in the certificate.
            int serverPort = newURI(transport).getPort();
            client.newRequest("https://127.0.0.2:" + serverPort)
                .timeout(5, TimeUnit.SECONDS)
                .send();
        });
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testOPTIONS(Transport transport) throws Exception
    {
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                assertTrue(HttpMethod.OPTIONS.is(request.getMethod()));
                assertEquals("*", Request.getPathInContext(request));
                callback.succeeded();
                return true;
            }
        });

        ContentResponse response = client.newRequest(newURI(transport))
            .method(HttpMethod.OPTIONS)
            .path("*")
            .timeout(5, TimeUnit.SECONDS)
            .send();

        assertEquals(HttpStatus.OK_200, response.getStatus());
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testOPTIONSWithRelativeRedirect(Transport transport) throws Exception
    {
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                if ("*".equals(Request.getPathInContext(request)))
                {
                    // Be nasty and send a relative redirect.
                    // Code 303 will change the method to GET.
                    response.setStatus(HttpStatus.SEE_OTHER_303);
                    response.getHeaders().put(HttpHeader.LOCATION, "/");
                }
                callback.succeeded();
                return true;
            }
        });

        ContentResponse response = client.newRequest(newURI(transport))
            .method(HttpMethod.OPTIONS)
            .path("*")
            .timeout(5, TimeUnit.SECONDS)
            .send();

        assertEquals(HttpStatus.OK_200, response.getStatus());
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testDownloadWithInputStreamResponseListener(Transport transport) throws Exception
    {
        String content = "hello world";
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                Content.Sink.write(response, true, content, callback);
                return true;
            }
        });

        CountDownLatch latch = new CountDownLatch(1);
        try (InputStreamResponseListener listener = new InputStreamResponseListener())
        {
            client.newRequest(newURI(transport))
                .onResponseSuccess(response -> latch.countDown())
                .send(listener);
            Response response = listener.get(5, TimeUnit.SECONDS);
            assertEquals(200, response.getStatus());

            // Response cannot succeed until we read the content.
            assertFalse(latch.await(500, TimeUnit.MILLISECONDS));

            InputStream input = listener.getInputStream();
            assertEquals(content, IO.toString(input));

            assertTrue(latch.await(5, TimeUnit.SECONDS));
        }
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testConnectionListener(Transport transport) throws Exception
    {
        start(transport, new EmptyServerHandler());
        long idleTimeout = 1000;
        client.setIdleTimeout(idleTimeout);

        CountDownLatch openLatch = new CountDownLatch(1);
        CountDownLatch closeLatch = new CountDownLatch(1);
        client.addBean(new org.eclipse.jetty.io.Connection.Listener()
        {
            @Override
            public void onOpened(org.eclipse.jetty.io.Connection connection)
            {
                openLatch.countDown();
            }

            @Override
            public void onClosed(org.eclipse.jetty.io.Connection connection)
            {
                closeLatch.countDown();
            }
        });

        ContentResponse response = client.newRequest(newURI(transport))
            .timeout(5, TimeUnit.SECONDS)
            .send();

        assertEquals(HttpStatus.OK_200, response.getStatus());
        assertTrue(openLatch.await(1, TimeUnit.SECONDS));

        Thread.sleep(2 * idleTimeout);
        assertTrue(closeLatch.await(1, TimeUnit.SECONDS));
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testAsyncResponseContentBackPressure(Transport transport) throws Exception
    {
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                // Large write to generate multiple DATA frames.
                response.write(true, ByteBuffer.allocate(256 * 1024), callback);
                return true;
            }
        });

        CountDownLatch completeLatch = new CountDownLatch(1);
        AtomicInteger counter = new AtomicInteger();
        AtomicReference<Runnable> demanderRef = new AtomicReference<>();
        AtomicReference<CountDownLatch> latchRef = new AtomicReference<>(new CountDownLatch(1));
        client.newRequest(newURI(transport))
            .onResponseContentAsync((response, chunk, demander) ->
            {
                if (counter.incrementAndGet() == 1)
                {
                    demanderRef.set(demander);
                    latchRef.get().countDown();
                }
                else
                {
                    demander.run();
                }
            })
            .send(result -> completeLatch.countDown());

        assertTrue(latchRef.get().await(5, TimeUnit.SECONDS));
        // Wait some time to verify that back pressure is applied correctly.
        Thread.sleep(1000);
        assertEquals(1, counter.get());
        demanderRef.get().run();

        assertTrue(completeLatch.await(5, TimeUnit.SECONDS));
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testResponseWithContentCompleteListenerInvokedOnce(Transport transport) throws Exception
    {
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                Content.Sink.write(response, true, "Jetty", callback);
                return true;
            }
        });

        AtomicInteger completes = new AtomicInteger();
        client.newRequest(newURI(transport))
            .send(result -> completes.incrementAndGet());

        sleep(1000);

        assertEquals(1, completes.get());
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testHEADResponds200(Transport transport) throws Exception
    {
        testHEAD(transport, "/", HttpStatus.OK_200);
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testHEADResponds404(Transport transport) throws Exception
    {
        testHEAD(transport, "/notMapped", HttpStatus.NOT_FOUND_404);
    }

    private void testHEAD(Transport transport, String path, int status) throws Exception
    {
        byte[] data = new byte[1024];
        new Random().nextBytes(data);
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                String target = Request.getPathInContext(request);
                if ("/notMapped".equals(target))
                    org.eclipse.jetty.server.Response.writeError(request, response, callback, HttpStatus.NOT_FOUND_404);
                else
                    response.write(true, ByteBuffer.wrap(data), callback);
                return true;
            }
        });

        ContentResponse response = client.newRequest(newURI(transport))
            .method(HttpMethod.HEAD)
            .path(path)
            .send();

        assertEquals(status, response.getStatus());
        assertEquals(0, response.getContent().length);
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testHEADWithAcceptHeaderAndSendError(Transport transport) throws Exception
    {
        int status = HttpStatus.BAD_REQUEST_400;
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                org.eclipse.jetty.server.Response.writeError(request, response, callback, status);
                return true;
            }
        });

        ContentResponse response = client.newRequest(newURI(transport))
            .method(HttpMethod.HEAD)
            .headers(headers -> headers.put(HttpHeader.ACCEPT, "*/*"))
            .send();

        assertEquals(status, response.getStatus());
        assertEquals(0, response.getContent().length);
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testHEADWithContentLengthGreaterThanMaxBufferingCapacity(Transport transport) throws Exception
    {
        int length = 1024;
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                response.getHeaders().put(HttpHeader.CONTENT_LENGTH, length);
                response.write(true, ByteBuffer.allocate(length), callback);
                return true;
            }
        });

        var request = client.newRequest(newURI(transport))
            .method(HttpMethod.HEAD);
        CompletableFuture<ContentResponse> completable = new CompletableResponseListener(request, length / 2).send();
        ContentResponse response = completable.get(5, TimeUnit.SECONDS);

        assertEquals(HttpStatus.OK_200, response.getStatus());
        assertEquals(0, response.getContent().length);
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testOneDestinationPerUser(Transport transport) throws Exception
    {
        start(transport, new EmptyServerHandler());
        int runs = 4;
        int users = 16;
        for (int i = 0; i < runs; ++i)
        {
            for (int j = 0; j < users; ++j)
            {
                ContentResponse response = client.newRequest(newURI(transport))
                    .tag(j)
                    .send();
                assertEquals(HttpStatus.OK_200, response.getStatus());
            }
        }

        List<Destination> destinations = client.getDestinations();
        assertEquals(users, destinations.size());
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testIPv6Host(Transport transport) throws Exception
    {
        assumeTrue(Net.isIpv6InterfaceAvailable());
        assumeTrue(transport != Transport.H3);

        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/plain");
                Content.Sink.write(response, true, request.getHeaders().get(HttpHeader.HOST), callback);
                return true;
            }
        });

        // Test with a full URI.
        String hostAddress = "::1";
        String host = "[" + hostAddress + "]";
        URI serverURI = newURI(transport);
        String uri = serverURI.toString().replace("localhost", host) + "/path";
        ContentResponse response = client.newRequest(uri)
            .method(HttpMethod.PUT)
            .timeout(5, TimeUnit.SECONDS)
            .send();
        assertNotNull(response);
        assertEquals(200, response.getStatus());
        assertThat(new String(response.getContent(), StandardCharsets.ISO_8859_1), Matchers.startsWith("[::1]:"));

        // Test with host address.
        int port = serverURI.getPort();
        response = client.newRequest(hostAddress, port)
            .scheme(serverURI.getScheme())
            .method(HttpMethod.PUT)
            .timeout(5, TimeUnit.SECONDS)
            .send();
        assertNotNull(response);
        assertEquals(200, response.getStatus());
        assertThat(new String(response.getContent(), StandardCharsets.ISO_8859_1), Matchers.startsWith("[::1]:"));

        // Test with host name.
        response = client.newRequest(host, port)
            .scheme(serverURI.getScheme())
            .method(HttpMethod.PUT)
            .timeout(5, TimeUnit.SECONDS)
            .send();
        assertNotNull(response);
        assertEquals(200, response.getStatus());
        assertThat(new String(response.getContent(), StandardCharsets.ISO_8859_1), Matchers.startsWith("[::1]:"));

        Assertions.assertEquals(1, client.getDestinations().size());
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testRequestWithDifferentDestination(Transport transport) throws Exception
    {
        String requestScheme = newURI(transport).getScheme();
        String requestHost = "otherHost.com";
        int requestPort = 8888;
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                HttpURI uri = request.getHttpURI();
                assertEquals(requestHost, uri.getHost());
                assertEquals(requestPort, uri.getPort());
                if (transport == Transport.H2C || transport == Transport.H2)
                    assertEquals(requestScheme, uri.getScheme());
                callback.succeeded();
                return true;
            }
        });
        if (transport.isSecure())
            httpConfig.getCustomizer(SecureRequestCustomizer.class).setSniHostCheck(false);

        Origin origin = new Origin(requestScheme, "localhost", ((NetworkConnector)connector).getLocalPort());
        Destination destination = client.resolveDestination(origin);

        var request = client.newRequest(requestHost, requestPort)
            .scheme(requestScheme)
            .path("/path");

        CountDownLatch resultLatch = new CountDownLatch(1);
        destination.send(request, result ->
        {
            assertTrue(result.isSucceeded());
            assertEquals(HttpStatus.OK_200, result.getResponse().getStatus());
            resultLatch.countDown();
        });

        assertTrue(resultLatch.await(5, TimeUnit.SECONDS));
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testRequestIdleTimeout(Transport transport) throws Exception
    {
        CountDownLatch latch = new CountDownLatch(1);
        long idleTimeout = 500;
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback) throws Exception
            {
                String target = Request.getPathInContext(request);
                if (target.equals("/1"))
                    assertTrue(latch.await(5, TimeUnit.SECONDS));
                else if (target.equals("/2"))
                    Thread.sleep(2 * idleTimeout);
                else
                    fail("Unknown path: " + target);
                callback.succeeded();
                return true;
            }
        });

        assertThrows(TimeoutException.class, () ->
            client.newRequest(newURI(transport))
                .path("/1")
                .idleTimeout(idleTimeout, TimeUnit.MILLISECONDS)
                .timeout(2 * idleTimeout, TimeUnit.MILLISECONDS)
                .send());
        latch.countDown();

        // Make another request without specifying the idle timeout, should not fail
        ContentResponse response = client.newRequest(newURI(transport))
            .path("/2")
            .timeout(3 * idleTimeout, TimeUnit.MILLISECONDS)
            .send();

        assertNotNull(response);
        assertEquals(200, response.getStatus());
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testContentSourceListeners(Transport transport) throws Exception
    {
        int totalBytes = 1024;
        start(transport, new TestHandler(totalBytes));

        List<Content.Chunk> chunks = new CopyOnWriteArrayList<>();
        CompleteContentSourceListener listener = new CompleteContentSourceListener()
        {
            @Override
            public void onContentSource(Response response, Content.Source contentSource)
            {
                accumulateChunks(contentSource, chunks);
            }
        };
        client.newRequest(newURI(transport))
            .path("/")
            .send(listener);
        listener.await(5, TimeUnit.SECONDS);

        assertThat(listener.result.getResponse().getStatus(), is(200));
        assertThat(chunks.stream().mapToInt(c -> c.getByteBuffer().remaining()).sum(), is(totalBytes));
        assertThat(chunks.get(chunks.size() - 1).isLast(), is(true));
    }

    @ParameterizedTest
    @MethodSource("transports")
    @Tag("DisableLeakTracking:client:H3")
    @Tag("DisableLeakTracking:client:FCGI")
    public void testContentSourceListenersFailure(Transport transport) throws Exception
    {
        int totalBytes = 1024;
        start(transport, new TestHandler(totalBytes));

        CompleteContentSourceListener listener = new CompleteContentSourceListener()
        {
            @Override
            public void onContentSource(Response response, Content.Source contentSource)
            {
                contentSource.fail(new Exception("Synthetic Failure"));
            }
        };
        client.newRequest(newURI(transport))
            .path("/")
            .send(listener);
        listener.await(5, TimeUnit.SECONDS);

        assertThat(listener.result.isFailed(), is(true));
        assertThat(listener.result.getFailure().getMessage(), is("Synthetic Failure"));
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testContentSourceListenerDemandInSpawnedThread(Transport transport) throws Exception
    {
        int totalBytes = 1024;
        start(transport, new TestHandler(totalBytes));

        List<Content.Chunk> chunks = new CopyOnWriteArrayList<>();
        CompleteContentSourceListener listener = new CompleteContentSourceListener()
        {
            @Override
            public void onContentSource(Response response, Content.Source contentSource)
            {
                new Thread(() -> accumulateChunksInSpawnedThread(contentSource, chunks))
                    .start();
            }
        };
        client.newRequest(newURI(transport))
            .path("/")
            .send(listener);
        listener.await(5, TimeUnit.SECONDS);

        assertThat(listener.result.getResponse().getStatus(), is(200));
        assertThat(chunks.stream().mapToInt(c -> c.getByteBuffer().remaining()).sum(), is(totalBytes));
        assertThat(chunks.get(chunks.size() - 1).isLast(), is(true));
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testParallelContentSourceListeners(Transport transport) throws Exception
    {
        int totalBytes = 1024;
        start(transport, new TestHandler(totalBytes));

        List<Content.Chunk> chunks1 = new CopyOnWriteArrayList<>();
        List<Content.Chunk> chunks2 = new CopyOnWriteArrayList<>();
        List<Content.Chunk> chunks3 = new CopyOnWriteArrayList<>();

        ContentResponse resp = client.newRequest(newURI(transport))
            .path("/")
            .onResponseContentSource((response, contentSource) -> accumulateChunks(contentSource, chunks1))
            .onResponseContentSource((response, contentSource) -> accumulateChunks(contentSource, chunks2))
            .onResponseContentSource((response, contentSource) -> accumulateChunks(contentSource, chunks3))
            .send();

        assertThat(resp.getStatus(), is(200));
        assertThat(resp.getContent().length, is(totalBytes));

        assertThat(chunks1.stream().mapToInt(c -> c.getByteBuffer().remaining()).sum(), is(totalBytes));
        assertThat(chunks1.get(chunks1.size() - 1).isLast(), is(true));
        assertThat(chunks2.stream().mapToInt(c -> c.getByteBuffer().remaining()).sum(), is(totalBytes));
        assertThat(chunks2.get(chunks2.size() - 1).isLast(), is(true));
        assertThat(chunks3.stream().mapToInt(c -> c.getByteBuffer().remaining()).sum(), is(totalBytes));
        assertThat(chunks3.get(chunks3.size() - 1).isLast(), is(true));
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testParallelContentSourceListenersPartialFailure(Transport transport) throws Exception
    {
        int totalBytes = 1024;
        start(transport, new TestHandler(totalBytes));

        List<Content.Chunk> chunks1 = new CopyOnWriteArrayList<>();
        List<Content.Chunk> chunks2 = new CopyOnWriteArrayList<>();
        List<Content.Chunk> chunks3 = new CopyOnWriteArrayList<>();
        ContentResponse contentResponse = client.newRequest(newURI(transport))
            .path("/")
            .onResponseContentSource((response, contentSource) -> accumulateChunks(contentSource, chunks1))
            .onResponseContentSource((response, contentSource) -> accumulateChunks(contentSource, chunks2))
            .onResponseContentSource((response, contentSource) ->
            {
                contentSource.fail(new Exception("Synthetic Failure"));
                contentSource.demand(() -> chunks3.add(contentSource.read()));
            })
            .send();
        assertThat(contentResponse.getStatus(), is(200));
        assertThat(contentResponse.getContent().length, is(totalBytes));

        assertThat(chunks1.stream().mapToInt(c -> c.getByteBuffer().remaining()).sum(), is(totalBytes));
        assertThat(chunks2.stream().mapToInt(c -> c.getByteBuffer().remaining()).sum(), is(totalBytes));
        assertThat(chunks3.stream().mapToInt(c -> c.getByteBuffer().remaining()).sum(), is(0));
        assertThat(chunks3.size(), is(1));
        assertTrue(Content.Chunk.isFailure(chunks3.get(0), true));

        chunks1.forEach(Content.Chunk::release);
        chunks2.forEach(Content.Chunk::release);
        chunks3.forEach(Content.Chunk::release);
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testParallelContentSourceListenersPartialFailureInSpawnedThread(Transport transport) throws Exception
    {
        int totalBytes = 1024;
        start(transport, new TestHandler(totalBytes));

        List<Content.Chunk> chunks1 = new CopyOnWriteArrayList<>();
        List<Content.Chunk> chunks2 = new CopyOnWriteArrayList<>();
        List<Content.Chunk> chunks3 = new CopyOnWriteArrayList<>();
        CountDownLatch chunks3Latch = new CountDownLatch(1);
        ContentResponse contentResponse = client.newRequest(newURI(transport))
            .path("/")
            .onResponseContentSource((response, contentSource) -> accumulateChunks(contentSource, chunks1))
            .onResponseContentSource((response, contentSource) -> accumulateChunks(contentSource, chunks2))
            .onResponseContentSource((response, contentSource) ->
                new Thread(() ->
                {
                    contentSource.fail(new Exception("Synthetic Failure"));
                    contentSource.demand(() ->
                    {
                        chunks3.add(contentSource.read());
                        chunks3Latch.countDown();
                    });
                }).start())
            .send();
        assertThat(contentResponse.getStatus(), is(200));
        assertThat(contentResponse.getContent().length, is(totalBytes));

        assertThat(chunks1.stream().mapToInt(c -> c.getByteBuffer().remaining()).sum(), is(totalBytes));
        assertThat(chunks2.stream().mapToInt(c -> c.getByteBuffer().remaining()).sum(), is(totalBytes));

        assertThat(chunks3Latch.await(5, TimeUnit.SECONDS), is(true));
        assertThat(chunks3.stream().mapToInt(c -> c.getByteBuffer().remaining()).sum(), is(0));
        assertThat(chunks3.size(), is(1));
        assertTrue(Content.Chunk.isFailure(chunks3.get(0), true));

        chunks1.forEach(Content.Chunk::release);
        chunks2.forEach(Content.Chunk::release);
        chunks3.forEach(Content.Chunk::release);
    }

    @ParameterizedTest
    @MethodSource("transports")
    @Tag("DisableLeakTracking:client:H3")
    @Tag("DisableLeakTracking:client:FCGI")
    public void testParallelContentSourceListenersTotalFailure(Transport transport) throws Exception
    {
        start(transport, new TestHandler(1024));

        CompleteContentSourceListener listener = new CompleteContentSourceListener()
        {
            @Override
            public void onContentSource(Response response, Content.Source contentSource)
            {
                contentSource.fail(new Exception("Synthetic Failure"));
            }
        };
        client.newRequest(newURI(transport))
            .path("/")
            .onResponseContentSource((response, contentSource) -> contentSource.fail(new Exception("Synthetic Failure")))
            .onResponseContentSource((response, contentSource) -> contentSource.fail(new Exception("Synthetic Failure")))
            .send(listener);
        assertThat(listener.await(5, TimeUnit.SECONDS), is(true));

        assertThat(listener.result.isFailed(), is(true));
        assertThat(listener.result.getFailure().getMessage(), is("Synthetic Failure"));
    }

    @ParameterizedTest
    @MethodSource("transports")
    public void testRequestConnection(Transport transport) throws Exception
    {
        start(transport, new EmptyServerHandler());

        ContentResponse response = client.newRequest(newURI(transport))
            .onRequestBegin(r ->
            {
                Connection connection = r.getConnection();
                if (connection == null)
                    r.abort(new IllegalStateException());
            })
            .onRequestHeaders(r ->
            {
                if (transport.isSecure())
                {
                    EndPoint.SslSessionData sslSessionData = r.getConnection().getSslSessionData();
                    if (sslSessionData == null)
                        r.abort(new IllegalStateException());
                }
            })
            .send();

        assertEquals(200, response.getStatus());
    }

    @ParameterizedTest
    @MethodSource("transportsNoFCGI")
    public void testInvalidExpectation(Transport transport) throws Exception
    {
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                Content.Source.consumeAll(request, callback);
                return true;
            }
        });

        CountDownLatch resultLatch = new CountDownLatch(1);
        AtomicReference<Response> responseRef = new AtomicReference<>();
        client.newRequest(newURI(transport))
            .headers(h -> h.put(HttpHeader.EXPECT, "Invalid"))
            // Body is necessary, otherwise the Expect header is removed.
            .body(new StringRequestContent("hello"))
            .onResponseHeaders(responseRef::set)
            .send(r -> resultLatch.countDown());

        // In HTTP/2, the request body is not read, as the error response
        // is sent without calling the Handler, so a reset is triggered
        // after the response is sent.
        // The test verifies that the right response is received at the
        // "headers" event, because the response body is read asynchronously
        // and may be dropped when the RST_STREAM frame is received.
        // Waiting for the "complete" event will likely result in a failure
        // due to the RST_STREAM being received.

        assertTrue(resultLatch.await(5, TimeUnit.SECONDS));
        Response response = responseRef.get();
        assertThat(response.getStatus(), equalTo(HttpStatus.EXPECTATION_FAILED_417));
    }

    public static java.util.stream.Stream<Arguments> validFieldValues()
    {
        List<Arguments> cases = new ArrayList<>();

        Collection<Transport> transports = transports();
        transports.remove(Transport.FCGI);

        for (Transport transport : transports)
        {
            cases.add(Arguments.of(transport, "va ue", "va ue"));
            cases.add(Arguments.of(transport, "va\tue", "va\tue"));
        }

        return cases.stream();
    }

    @ParameterizedTest
    @MethodSource("validFieldValues")
    public void testValidFieldValues(Transport transport, String rawValue, String expectedValue) throws Exception
    {
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                String value = request.getHeaders().get("name");
                String msg = "name:[%s]".formatted(value);
                Content.Sink.write(response, true, msg, callback);
                return true;
            }
        });

        ContentResponse response = client.newRequest(newURI(transport))
            .method("GET")
            .headers((headers) ->
            {
                headers.put("name", rawValue);
            })
            .send();

        assertThat(response.getContentAsString(), is("name:[" + expectedValue + "]"));
        assertThat(response.getStatus(), is(200));
    }

    public static java.util.stream.Stream<Arguments> validFieldValueContentType()
    {
        List<Arguments> cases = new ArrayList<>();

        Collection<Transport> transports = transports();
        transports.remove(Transport.FCGI);

        for (Transport transport : transports)
        {
            // NOTE: this entire field value is cached.
            cases.add(Arguments.of(transport, "text/plain; charset=UTF-8", "text/plain; charset=UTF-8"));
            cases.add(Arguments.of(transport, "text/plain;charset=UTF-8", "text/plain;charset=UTF-8"));
            cases.add(Arguments.of(transport, "text/plain; \tcharset=UTF-8", "text/plain; \tcharset=UTF-8"));
        }

        return cases.stream();
    }

    @ParameterizedTest
    @MethodSource("validFieldValueContentType")
    public void testValidFieldValueContentType(Transport transport, String rawValue, String expectedValue) throws Exception
    {
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                String value = request.getHeaders().get(HttpHeader.CONTENT_TYPE);
                String msg = "content-type:[%s]".formatted(value);
                Content.Sink.write(response, true, msg, callback);
                return true;
            }
        });

        ContentResponse response = client.newRequest(newURI(transport))
            .method("GET")
            .headers((headers) ->
            {
                headers.put("content-type", rawValue);
            })
            .send();

        assertThat(response.getContentAsString(), is("content-type:[" + expectedValue + "]"));
        assertThat(response.getStatus(), is(200));
    }

    public static java.util.stream.Stream<Arguments> invalidFieldValues()
    {
        List<Arguments> cases = new ArrayList<>();

        Collection<Transport> transports = transports();
        transports.remove(Transport.FCGI);
        transports.remove(Transport.HTTP);
        transports.remove(Transport.HTTPS);

        for (Transport transport : transports)
        {
            cases.add(Arguments.of(transport, "\tvalue"));
            cases.add(Arguments.of(transport, "value\t"));
            cases.add(Arguments.of(transport, " value"));
            cases.add(Arguments.of(transport, " value "));
        }

        return cases.stream();
    }

    @ParameterizedTest
    @MethodSource("invalidFieldValues")
    public void testInvalidFieldValues(Transport transport, String rawValue) throws Exception
    {
        start(transport, new Handler.Abstract()
        {
            @Override
            public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
            {
                String value = request.getHeaders().get("name");
                String msg = "name:[%s]".formatted(value);
                Content.Sink.write(response, true, msg, callback);
                return true;
            }
        });

        ExecutionException exception = assertThrows(ExecutionException.class, () ->
        {
            client.newRequest(newURI(transport))
                .method("GET")
                .headers((headers) ->
                {
                    headers.put("name", rawValue);
                })
                .send();
        });

        assertThat(exception.getMessage(), containsString("Invalid 'name' header value"));
    }

    private static void sleep(long time) throws IOException
    {
        try
        {
            Thread.sleep(time);
        }
        catch (InterruptedException x)
        {
            throw new InterruptedIOException();
        }
    }

    private static void accumulateChunks(Content.Source contentSource, List<Content.Chunk> chunks)
    {
        Content.Chunk chunk = contentSource.read();
        if (chunk == null)
        {
            contentSource.demand(() -> accumulateChunks(contentSource, chunks));
            return;
        }

        chunks.add(duplicate(chunk));
        chunk.release();

        if (!chunk.isLast())
            contentSource.demand(() -> accumulateChunks(contentSource, chunks));
    }

    private static void accumulateChunksInSpawnedThread(Content.Source contentSource, List<Content.Chunk> chunks)
    {
        Content.Chunk chunk = contentSource.read();
        if (chunk == null)
        {
            contentSource.demand(() -> new Thread(() -> accumulateChunks(contentSource, chunks)).start());
            return;
        }

        chunks.add(duplicate(chunk));
        chunk.release();

        if (!chunk.isLast())
            contentSource.demand(() -> new Thread(() -> accumulateChunks(contentSource, chunks)).start());
    }

    private static Content.Chunk duplicate(Content.Chunk chunk)
    {
        if (chunk.hasRemaining())
        {
            ByteBuffer byteBuffer = BufferUtil.allocate(chunk.remaining());
            int pos = BufferUtil.flipToFill(byteBuffer);
            byteBuffer.put(chunk.getByteBuffer());
            BufferUtil.flipToFlush(byteBuffer, pos);
            return Content.Chunk.from(byteBuffer, chunk.isLast());
        }
        else
        {
            return chunk.isLast() ? Content.Chunk.EOF : Content.Chunk.EMPTY;
        }
    }

    private static class TestHandler extends Handler.Abstract
    {
        private final int totalBytes;

        private TestHandler(int totalBytes)
        {
            this.totalBytes = totalBytes;
        }

        @Override
        public boolean handle(Request request, org.eclipse.jetty.server.Response response, Callback callback)
        {
            response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/plain");

            IteratingCallback iteratingCallback = new IteratingNestedCallback(callback)
            {
                int count = 0;
                @Override
                protected Action process()
                {
                    boolean last = ++count == totalBytes;
                    if (count > totalBytes)
                        return Action.SUCCEEDED;
                    response.write(last, ByteBuffer.wrap(new byte[1]), this);
                    return Action.SCHEDULED;
                }
            };
            iteratingCallback.iterate();
            return true;
        }
    }

    private abstract static class CompleteContentSourceListener implements Response.CompleteListener, Response.ContentSourceListener
    {
        private final CountDownLatch latch = new CountDownLatch(1);
        private Result result;

        @Override
        public void onComplete(Result result)
        {
            this.result = result;
            latch.countDown();
        }

        public boolean await(long timeout, TimeUnit unit) throws InterruptedException
        {
            return latch.await(timeout, unit);
        }
    }

    private static class OnContentSourceListener implements Response.Listener
    {
        record ClientResponseContent(int status, String body, HttpFields trailers)
        {
        }

        final CompletableFuture<ClientResponseContent> clientResponseContent = new CompletableFuture<>();
        final StringBuffer buffer = new StringBuffer();

        @Override
        public void onContentSource(Response response, Content.Source contentSource)
        {
            new Thread(() ->
            {
                Content.Chunk chunk = contentSource.read();
                if (chunk == null)
                {
                    contentSource.demand(() -> onContentSource(response, contentSource));
                    return;
                }

                buffer.append(BufferUtil.toString(chunk.getByteBuffer(), StandardCharsets.UTF_8));
                chunk.release();

                if (!chunk.isLast())
                {
                    contentSource.demand(() -> onContentSource(response, contentSource));
                }
                else
                {
                    Content.Chunk afterLastChunk = contentSource.read();
                    if (afterLastChunk != chunk)
                        clientResponseContent.completeExceptionally(new AssertionError("afterLastChunk != chunk"));
                    else
                        clientResponseContent.complete(new ClientResponseContent(response.getStatus(), buffer.toString(), response.getTrailers()));
                }
            }
            ).start();
        }
    }
}
