// Copyright 2016 The Bazel Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//    http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.devtools.build.lib.bazel.repository.downloader;

import static com.google.common.truth.Truth.assertThat;
import static com.google.devtools.build.lib.bazel.repository.downloader.DownloaderTestUtils.sendLines;
import static com.google.devtools.build.lib.bazel.repository.downloader.HttpParser.readHttpRequest;
import static java.nio.charset.StandardCharsets.ISO_8859_1;
import static java.nio.charset.StandardCharsets.US_ASCII;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.Assert.fail;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.google.common.collect.ImmutableMap;
import com.google.common.io.ByteStreams;
import com.google.common.io.CharStreams;
import com.google.devtools.build.lib.events.EventHandler;
import com.google.devtools.build.lib.testutil.ManualClock;
import com.google.devtools.build.lib.testutil.ManualSleeper;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.InetAddress;
import java.net.Proxy;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URL;
import java.net.URLConnection;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.rules.TemporaryFolder;
import org.junit.rules.Timeout;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/** Unit tests for {@link HttpConnector}. */
@RunWith(JUnit4.class)
public class HttpConnectorTest {

  @Rule
  public final ExpectedException thrown = ExpectedException.none();

  @Rule
  public final TemporaryFolder testFolder = new TemporaryFolder();

  @Rule
  public final Timeout globalTimeout = new Timeout(10000);

  private final ExecutorService executor = Executors.newFixedThreadPool(2);
  private final ManualClock clock = new ManualClock();
  private final ManualSleeper sleeper = new ManualSleeper(clock);
  private final EventHandler eventHandler = mock(EventHandler.class);
  private final ProxyHelper proxyHelper = mock(ProxyHelper.class);
  private final HttpConnector connector =
      new HttpConnector(Locale.US, eventHandler, proxyHelper, sleeper);

  @Before
  public void before() throws Exception {
    when(proxyHelper.createProxyIfNeeded(any(URL.class))).thenReturn(Proxy.NO_PROXY);
  }

  @After
  public void after() throws Exception {
    executor.shutdown();
  }

  @Test
  public void localFileDownload() throws Exception {
    byte[] fileContents = "this is a test".getBytes(UTF_8);
    assertThat(
            ByteStreams.toByteArray(
                connector.connect(
                        createTempFile(fileContents).toURI().toURL(),
                        ImmutableMap.<String, String>of())
                    .getInputStream()))
        .isEqualTo(fileContents);
  }

  @Test
  public void badHost_throwsIOException() throws Exception {
    thrown.expect(IOException.class);
    thrown.expectMessage("Unknown host: bad.example");
    connector.connect(new URL("http://bad.example"), ImmutableMap.<String, String>of());
  }

  @Test
  public void normalRequest() throws Exception {
    final Map<String, String> headers = new ConcurrentHashMap<>();
    try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
      executor.submit(
          new Callable<Object>() {
            @Override
            public Object call() throws Exception {
              try (Socket socket = server.accept()) {
                readHttpRequest(socket.getInputStream(), headers);
                sendLines(socket,
                    "HTTP/1.1 200 OK",
                    "Date: Fri, 31 Dec 1999 23:59:59 GMT",
                    "Connection: close",
                    "Content-Type: text/plain",
                    "Content-Length: 5",
                    "",
                    "hello");
              }
              return null;
            }
          });
      try (Reader payload =
              new InputStreamReader(
                  connector.connect(
                          new URL(String.format("http://127.0.0.1:%d/boo", server.getLocalPort())),
                          ImmutableMap.of("Content-Encoding", "gzip"))
                      .getInputStream(),
                  ISO_8859_1)) {
        assertThat(CharStreams.toString(payload)).isEqualTo("hello");
      }
    }
    assertThat(headers).containsEntry("x-method", "GET");
    assertThat(headers).containsEntry("x-request-uri", "/boo");
    assertThat(headers).containsEntry("content-encoding", "gzip");
  }

  @Test
  public void serverError_retriesConnect() throws Exception {
    try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
      executor.submit(
          new Callable<Object>() {
            @Override
            public Object call() throws Exception {
              try (Socket socket = server.accept()) {
                readHttpRequest(socket.getInputStream());
                sendLines(socket,
                    "HTTP/1.1 500 Incredible Catastrophe",
                    "Date: Fri, 31 Dec 1999 23:59:59 GMT",
                    "Connection: close",
                    "Content-Type: text/plain",
                    "Content-Length: 8",
                    "",
                    "nononono");
              }
              try (Socket socket = server.accept()) {
                readHttpRequest(socket.getInputStream());
                sendLines(socket,
                    "HTTP/1.1 200 OK",
                    "Date: Fri, 31 Dec 1999 23:59:59 GMT",
                    "Connection: close",
                    "Content-Type: text/plain",
                    "Content-Length: 5",
                    "",
                    "hello");
              }
              return null;
            }
          });
      try (Reader payload =
              new InputStreamReader(
                  connector.connect(
                          new URL(String.format("http://127.0.0.1:%d", server.getLocalPort())),
                          ImmutableMap.<String, String>of())
                      .getInputStream(),
                  ISO_8859_1)) {
        assertThat(CharStreams.toString(payload)).isEqualTo("hello");
        assertThat(clock.currentTimeMillis()).isEqualTo(100L);
      }
    }
  }

  @Test
  public void permanentError_doesNotRetryAndThrowsIOException() throws Exception {
    try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
      executor.submit(
          new Callable<Object>() {
            @Override
            public Object call() throws Exception {
              try (Socket socket = server.accept()) {
                readHttpRequest(socket.getInputStream());
                sendLines(socket,
                    "HTTP/1.1 404 Not Here",
                    "Date: Fri, 31 Dec 1999 23:59:59 GMT",
                    "Connection: close",
                    "Content-Type: text/plain",
                    "Content-Length: 0",
                    "",
                    "");
              }
              return null;
            }
          });
      thrown.expect(IOException.class);
      thrown.expectMessage("404 Not Here");
      connector.connect(
          new URL(String.format("http://127.0.0.1:%d", server.getLocalPort())),
          ImmutableMap.<String, String>of());
    }
  }

  @Test
  public void permanentError_consumesPayloadBeforeReturningn() throws Exception {
    final CyclicBarrier barrier = new CyclicBarrier(2);
    final AtomicBoolean consumed = new AtomicBoolean();
    try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
      executor.submit(
          new Callable<Object>() {
            @Override
            public Object call() throws Exception {
              try (Socket socket = server.accept()) {
                readHttpRequest(socket.getInputStream());
                sendLines(socket,
                    "HTTP/1.1 501 Oh No",
                    "Date: Fri, 31 Dec 1999 23:59:59 GMT",
                    "Connection: close",
                    "Content-Type: text/plain",
                    "Content-Length: 1",
                    "",
                    "b");
                consumed.set(true);
              } finally {
                barrier.await();
              }
              return null;
            }
          });
      connector.connect(
          new URL(String.format("http://127.0.0.1:%d", server.getLocalPort())),
          ImmutableMap.<String, String>of());
      fail();
    } catch (IOException ignored) {
      // ignored
    } finally {
      barrier.await();
    }
    assertThat(consumed.get()).isTrue();
    assertThat(clock.currentTimeMillis()).isEqualTo(0L);
  }

  @Test
  public void always500_givesUpEventually() throws Exception {
    final AtomicInteger tries = new AtomicInteger();
    try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
      executor.submit(new Callable<Object>() {
            @Override
            public Object call() throws Exception {
              while (true) {
                try (Socket socket = server.accept()) {
                  readHttpRequest(socket.getInputStream());
                  sendLines(socket,
                      "HTTP/1.1 500 Oh My",
                      "Date: Fri, 31 Dec 1999 23:59:59 GMT",
                      "Connection: close",
                      "Content-Type: text/plain",
                      "Content-Length: 0",
                      "",
                      "");
                  tries.incrementAndGet();
                }
              }
            }
          });
      thrown.expect(IOException.class);
      thrown.expectMessage("500 Oh My");
      try {
        connector.connect(
            new URL(String.format("http://127.0.0.1:%d", server.getLocalPort())),
            ImmutableMap.<String, String>of());
      } finally {
        assertThat(tries.get()).isGreaterThan(2);
      }
    }
  }

  @Test
  public void serverSays403_clientRetriesAnyway() throws Exception {
    final AtomicInteger tries = new AtomicInteger();
    try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
      executor.submit(
          new Callable<Object>() {
            @Override
            public Object call() throws Exception {
              while (true) {
                try (Socket socket = server.accept()) {
                  readHttpRequest(socket.getInputStream());
                  sendLines(socket,
                      "HTTP/1.1 403 Forbidden",
                      "Date: Fri, 31 Dec 1999 23:59:59 GMT",
                      "Connection: close",
                      "Content-Type: text/plain",
                      "Content-Length: 0",
                      "",
                      "");
                  tries.incrementAndGet();
                }
              }
            }
          });
      thrown.expect(IOException.class);
      thrown.expectMessage("403 Forbidden");
      try {
        connector.connect(
            new URL(String.format("http://127.0.0.1:%d", server.getLocalPort())),
            ImmutableMap.<String, String>of());
      } finally {
        assertThat(tries.get()).isGreaterThan(2);
      }
    }
  }

  @Test
  public void redirectToDifferentPath_works() throws Exception {
    final Map<String, String> headers1 = new ConcurrentHashMap<>();
    final Map<String, String> headers2 = new ConcurrentHashMap<>();
    try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
      executor.submit(
          new Callable<Object>() {
            @Override
            public Object call() throws Exception {
              try (Socket socket = server.accept()) {
                readHttpRequest(socket.getInputStream(), headers1);
                sendLines(socket,
                    "HTTP/1.1 301 Redirect",
                    "Date: Fri, 31 Dec 1999 23:59:59 GMT",
                    "Connection: close",
                    "Location: /doodle.tar.gz",
                    "Content-Length: 0",
                    "",
                    "");
              }
              try (Socket socket = server.accept()) {
                readHttpRequest(socket.getInputStream(), headers2);
                sendLines(socket,
                    "HTTP/1.1 200 OK",
                    "Date: Fri, 31 Dec 1999 23:59:59 GMT",
                    "Connection: close",
                    "Content-Type: text/plain",
                    "Content-Length: 0",
                    "",
                    "");
              }
              return null;
            }
          });
      URLConnection connection =
          connector.connect(
              new URL(String.format("http://127.0.0.1:%d", server.getLocalPort())),
              ImmutableMap.<String, String>of());
      assertThat(connection.getURL()).isEqualTo(
          new URL(String.format("http://127.0.0.1:%d/doodle.tar.gz", server.getLocalPort())));
      try (InputStream input = connection.getInputStream()) {
        assertThat(ByteStreams.toByteArray(input)).isEmpty();
      }
    }
    assertThat(headers1).containsEntry("x-request-uri", "/");
    assertThat(headers2).containsEntry("x-request-uri", "/doodle.tar.gz");
  }

  @Test
  public void redirectToDifferentServer_works() throws Exception {
    try (ServerSocket server1 = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"));
        ServerSocket server2 = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
      executor.submit(
          new Callable<Object>() {
            @Override
            public Object call() throws Exception {
              try (Socket socket = server1.accept()) {
                readHttpRequest(socket.getInputStream());
                sendLines(socket,
                    "HTTP/1.1 301 Redirect",
                    "Date: Fri, 31 Dec 1999 23:59:59 GMT",
                    "Connection: close",
                    String.format("Location: http://127.0.0.1:%d/doodle.tar.gz",
                        server2.getLocalPort()),
                    "Content-Length: 0",
                    "",
                    "");
              }
              return null;
            }
          });
      executor.submit(
          new Callable<Object>() {
            @Override
            public Object call() throws Exception {
              try (Socket socket = server2.accept()) {
                readHttpRequest(socket.getInputStream());
                sendLines(socket,
                    "HTTP/1.1 200 OK",
                    "Date: Fri, 31 Dec 1999 23:59:59 GMT",
                    "Connection: close",
                    "Content-Type: text/plain",
                    "Content-Length: 5",
                    "",
                    "hello");
              }
              return null;
            }
          });
      URLConnection connection =
          connector.connect(
              new URL(String.format("http://127.0.0.1:%d", server1.getLocalPort())),
              ImmutableMap.<String, String>of());
      assertThat(connection.getURL()).isEqualTo(
          new URL(String.format("http://127.0.0.1:%d/doodle.tar.gz", server2.getLocalPort())));
      try (InputStream input = connection.getInputStream()) {
        assertThat(ByteStreams.toByteArray(input)).isEqualTo("hello".getBytes(US_ASCII));
      }
    }
  }

  private File createTempFile(byte[] fileContents) throws IOException {
    File temp = testFolder.newFile();
    try (FileOutputStream outputStream = new FileOutputStream(temp)) {
      outputStream.write(fileContents);
    }
    return temp;
  }
}
