// 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.io.ByteStreams.toByteArray;
import static com.google.common.truth.Truth.assertThat;
import static com.google.devtools.build.lib.bazel.repository.downloader.DownloaderTestUtils.makeUrl;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Arrays.asList;
import static org.junit.Assert.fail;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyLong;
import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.eq;
import static org.mockito.Matchers.same;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.devtools.build.lib.bazel.repository.downloader.RetryingInputStream.Reconnector;
import com.google.devtools.build.lib.events.EventHandler;
import com.google.devtools.build.lib.testutil.ManualClock;
import com.google.devtools.build.lib.util.Sleeper;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.URL;
import java.net.URLConnection;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.rules.Timeout;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;

/** Unit tests for {@link HttpConnectorMultiplexer}. */
@RunWith(JUnit4.class)
@SuppressWarnings("unchecked")
public class HttpConnectorMultiplexerTest {

  private static final URL URL1 = makeUrl("http://first.example");
  private static final URL URL2 = makeUrl("http://second.example");
  private static final URL URL3 = makeUrl("http://third.example");
  private static final byte[] data1 = "first".getBytes(UTF_8);
  private static final byte[] data2 = "second".getBytes(UTF_8);
  private static final byte[] data3 = "third".getBytes(UTF_8);

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

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

  private final HttpStream stream1 = fakeStream(URL1, data1);
  private final HttpStream stream2 = fakeStream(URL2, data2);
  private final HttpStream stream3 = fakeStream(URL3, data3);
  private final ManualClock clock = new ManualClock();
  private final Sleeper sleeper = mock(Sleeper.class);
  private final HttpConnector connector = mock(HttpConnector.class);
  private final URLConnection connection1 = mock(URLConnection.class);
  private final URLConnection connection2 = mock(URLConnection.class);
  private final URLConnection connection3 = mock(URLConnection.class);
  private final EventHandler eventHandler = mock(EventHandler.class);
  private final HttpStream.Factory streamFactory = mock(HttpStream.Factory.class);
  private final HttpConnectorMultiplexer multiplexer =
      new HttpConnectorMultiplexer(eventHandler, connector, streamFactory, clock, sleeper);

  @Before
  public void before() throws Exception {
    when(connector.connect(eq(URL1), any(ImmutableMap.class))).thenReturn(connection1);
    when(connector.connect(eq(URL2), any(ImmutableMap.class))).thenReturn(connection2);
    when(connector.connect(eq(URL3), any(ImmutableMap.class))).thenReturn(connection3);
    when(streamFactory
            .create(same(connection1), any(URL.class), anyString(), any(Reconnector.class)))
        .thenReturn(stream1);
    when(streamFactory
            .create(same(connection2), any(URL.class), anyString(), any(Reconnector.class)))
        .thenReturn(stream2);
    when(streamFactory
            .create(same(connection3), any(URL.class), anyString(), any(Reconnector.class)))
        .thenReturn(stream3);
  }

  @Test
  public void emptyList_throwsIae() throws Exception {
    thrown.expect(IllegalArgumentException.class);
    multiplexer.connect(ImmutableList.<URL>of(), "");
  }

  @Test
  public void ftpUrl_throwsIae() throws Exception {
    thrown.expect(IllegalArgumentException.class);
    multiplexer.connect(asList(new URL("ftp://lol.example")), "");
  }

  @Test
  public void threadIsInterrupted_throwsIeProntoAndDoesNothingElse() throws Exception {
    final AtomicBoolean wasInterrupted = new AtomicBoolean(true);
    Thread task = new Thread(
        new Runnable() {
          @Override
          public void run() {
            Thread.currentThread().interrupt();
            try {
              multiplexer.connect(asList(new URL("http://lol.example")), "");
            } catch (InterruptedIOException ignored) {
              return;
            } catch (Exception ignored) {
              // ignored
            }
            wasInterrupted.set(false);
          }
        });
    task.start();
    task.join();
    assertThat(wasInterrupted.get()).isTrue();
    verifyZeroInteractions(connector);
  }

  @Test
  public void singleUrl_justCallsConnector() throws Exception {
    assertThat(toByteArray(multiplexer.connect(asList(URL1), "abc"))).isEqualTo(data1);
    verify(connector).connect(eq(URL1), any(ImmutableMap.class));
    verify(streamFactory)
        .create(any(URLConnection.class), any(URL.class), eq("abc"), any(Reconnector.class));
    verifyNoMoreInteractions(sleeper, connector, streamFactory);
  }

  @Test
  public void multipleUrlsFail_throwsIOException() throws Exception {
    when(connector.connect(any(URL.class), any(ImmutableMap.class))).thenThrow(new IOException());
    try {
      multiplexer.connect(asList(URL1, URL2, URL3), "");
      fail("Expected IOException");
    } catch (IOException e) {
      assertThat(e.getMessage()).contains("All mirrors are down");
    }
    verify(connector, times(3)).connect(any(URL.class), any(ImmutableMap.class));
    verify(sleeper, times(2)).sleepMillis(anyLong());
    verifyNoMoreInteractions(sleeper, connector, streamFactory);
  }

  @Test
  public void firstUrlFails_returnsSecond() throws Exception {
    doAnswer(
        new Answer<Void>() {
          @Override
          public Void answer(InvocationOnMock invocation) throws Throwable {
            clock.advanceMillis(1000);
            return null;
          }
        }).when(sleeper).sleepMillis(anyLong());
    when(connector.connect(eq(URL1), any(ImmutableMap.class))).thenThrow(new IOException());
    assertThat(toByteArray(multiplexer.connect(asList(URL1, URL2), "abc"))).isEqualTo(data2);
    assertThat(clock.currentTimeMillis()).isEqualTo(1000L);
    verify(connector).connect(eq(URL1), any(ImmutableMap.class));
    verify(connector).connect(eq(URL2), any(ImmutableMap.class));
    verify(streamFactory)
        .create(any(URLConnection.class), any(URL.class), eq("abc"), any(Reconnector.class));
    verify(sleeper).sleepMillis(anyLong());
    verifyNoMoreInteractions(sleeper, connector, streamFactory);
  }

  @Test
  public void twoSuccessfulUrlsAndFirstWins_returnsFirstAndInterruptsSecond() throws Exception {
    final CyclicBarrier barrier = new CyclicBarrier(2);
    final AtomicBoolean wasInterrupted = new AtomicBoolean(true);
    when(connector.connect(eq(URL1), any(ImmutableMap.class))).thenAnswer(
        new Answer<URLConnection>() {
          @Override
          public URLConnection answer(InvocationOnMock invocation) throws Throwable {
            barrier.await();
            return connection1;
          }
        });
    doAnswer(
        new Answer<Void>() {
          @Override
          public Void answer(InvocationOnMock invocation) throws Throwable {
            barrier.await();
            TimeUnit.MILLISECONDS.sleep(10000);
            wasInterrupted.set(false);
            return null;
          }
        }).when(sleeper).sleepMillis(anyLong());
    assertThat(toByteArray(multiplexer.connect(asList(URL1, URL2), "abc"))).isEqualTo(data1);
    assertThat(wasInterrupted.get()).isTrue();
  }

  @Test
  public void parentThreadGetsInterrupted_interruptsChildrenThenThrowsIe() throws Exception {
    final CyclicBarrier barrier = new CyclicBarrier(3);
    final AtomicBoolean wasInterrupted1 = new AtomicBoolean(true);
    final AtomicBoolean wasInterrupted2 = new AtomicBoolean(true);
    final AtomicBoolean wasInterrupted3 = new AtomicBoolean(true);
    when(connector.connect(eq(URL1), any(ImmutableMap.class))).thenAnswer(
        new Answer<URLConnection>() {
          @Override
          public URLConnection answer(InvocationOnMock invocation) throws Throwable {
            barrier.await();
            TimeUnit.MILLISECONDS.sleep(10000);
            wasInterrupted1.set(false);
            throw new RuntimeException();
          }
        });
    when(connector.connect(eq(URL2), any(ImmutableMap.class))).thenAnswer(
        new Answer<URLConnection>() {
          @Override
          public URLConnection answer(InvocationOnMock invocation) throws Throwable {
            barrier.await();
            TimeUnit.MILLISECONDS.sleep(10000);
            wasInterrupted2.set(false);
            throw new RuntimeException();
          }
        });
    Thread task = new Thread(
        new Runnable() {
          @Override
          public void run() {
            try {
              multiplexer.connect(asList(URL1, URL2), "");
            } catch (InterruptedIOException ignored) {
              return;
            } catch (Exception ignored) {
              // ignored
            }
            wasInterrupted3.set(false);
          }
        });
    task.start();
    barrier.await();
    task.interrupt();
    task.join();
    assertThat(wasInterrupted1.get()).isTrue();
    assertThat(wasInterrupted2.get()).isTrue();
    assertThat(wasInterrupted3.get()).isTrue();
  }

  private static HttpStream fakeStream(URL url, byte[] data) {
    return new HttpStream(new ByteArrayInputStream(data), url);
  }
}
