package okhttp3;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import okhttp3.RealCall.AsyncCall;
import org.junit.Before;
import org.junit.Test;

import static java.util.concurrent.TimeUnit.SECONDS;
import static okhttp3.TestUtil.defaultClient;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

public final class DispatcherTest {
  RecordingExecutor executor = new RecordingExecutor();
  RecordingCallback callback = new RecordingCallback();
  RecordingWebSocketListener webSocketListener = new RecordingWebSocketListener();
  Dispatcher dispatcher = new Dispatcher(executor);
  OkHttpClient client = defaultClient().newBuilder()
      .dispatcher(dispatcher)
      .build();

  @Before public void setUp() throws Exception {
    dispatcher.setMaxRequests(20);
    dispatcher.setMaxRequestsPerHost(10);
  }

  @Test public void maxRequestsZero() throws Exception {
    try {
      dispatcher.setMaxRequests(0);
      fail();
    } catch (IllegalArgumentException expected) {
    }
  }

  @Test public void maxPerHostZero() throws Exception {
    try {
      dispatcher.setMaxRequestsPerHost(0);
      fail();
    } catch (IllegalArgumentException expected) {
    }
  }

  @Test public void enqueuedJobsRunImmediately() throws Exception {
    client.newCall(newRequest("http://a/1")).enqueue(callback);
    executor.assertJobs("http://a/1");
  }

  @Test public void maxRequestsEnforced() throws Exception {
    dispatcher.setMaxRequests(3);
    client.newCall(newRequest("http://a/1")).enqueue(callback);
    client.newCall(newRequest("http://a/2")).enqueue(callback);
    client.newCall(newRequest("http://b/1")).enqueue(callback);
    client.newCall(newRequest("http://b/2")).enqueue(callback);
    executor.assertJobs("http://a/1", "http://a/2", "http://b/1");
  }

  @Test public void maxPerHostEnforced() throws Exception {
    dispatcher.setMaxRequestsPerHost(2);
    client.newCall(newRequest("http://a/1")).enqueue(callback);
    client.newCall(newRequest("http://a/2")).enqueue(callback);
    client.newCall(newRequest("http://a/3")).enqueue(callback);
    executor.assertJobs("http://a/1", "http://a/2");
  }

  @Test public void maxPerHostNotEnforcedForWebSockets() {
    dispatcher.setMaxRequestsPerHost(2);
    client.newWebSocket(newRequest("http://a/1"), webSocketListener);
    client.newWebSocket(newRequest("http://a/2"), webSocketListener);
    client.newWebSocket(newRequest("http://a/3"), webSocketListener);
    executor.assertJobs("http://a/1", "http://a/2", "http://a/3");
  }

  @Test public void increasingMaxRequestsPromotesJobsImmediately() throws Exception {
    dispatcher.setMaxRequests(2);
    client.newCall(newRequest("http://a/1")).enqueue(callback);
    client.newCall(newRequest("http://b/1")).enqueue(callback);
    client.newCall(newRequest("http://c/1")).enqueue(callback);
    client.newCall(newRequest("http://a/2")).enqueue(callback);
    client.newCall(newRequest("http://b/2")).enqueue(callback);
    dispatcher.setMaxRequests(4);
    executor.assertJobs("http://a/1", "http://b/1", "http://c/1", "http://a/2");
  }

  @Test public void increasingMaxPerHostPromotesJobsImmediately() throws Exception {
    dispatcher.setMaxRequestsPerHost(2);
    client.newCall(newRequest("http://a/1")).enqueue(callback);
    client.newCall(newRequest("http://a/2")).enqueue(callback);
    client.newCall(newRequest("http://a/3")).enqueue(callback);
    client.newCall(newRequest("http://a/4")).enqueue(callback);
    client.newCall(newRequest("http://a/5")).enqueue(callback);
    dispatcher.setMaxRequestsPerHost(4);
    executor.assertJobs("http://a/1", "http://a/2", "http://a/3", "http://a/4");
  }

  @Test public void oldJobFinishesNewJobCanRunDifferentHost() throws Exception {
    dispatcher.setMaxRequests(1);
    client.newCall(newRequest("http://a/1")).enqueue(callback);
    client.newCall(newRequest("http://b/1")).enqueue(callback);
    executor.finishJob("http://a/1");
    executor.assertJobs("http://b/1");
  }

  @Test public void oldJobFinishesNewJobWithSameHostStarts() throws Exception {
    dispatcher.setMaxRequests(2);
    dispatcher.setMaxRequestsPerHost(1);
    client.newCall(newRequest("http://a/1")).enqueue(callback);
    client.newCall(newRequest("http://b/1")).enqueue(callback);
    client.newCall(newRequest("http://b/2")).enqueue(callback);
    client.newCall(newRequest("http://a/2")).enqueue(callback);
    executor.finishJob("http://a/1");
    executor.assertJobs("http://b/1", "http://a/2");
  }

  @Test public void oldJobFinishesNewJobCantRunDueToHostLimit() throws Exception {
    dispatcher.setMaxRequestsPerHost(1);
    client.newCall(newRequest("http://a/1")).enqueue(callback);
    client.newCall(newRequest("http://b/1")).enqueue(callback);
    client.newCall(newRequest("http://a/2")).enqueue(callback);
    executor.finishJob("http://b/1");
    executor.assertJobs("http://a/1");
  }

  @Test public void cancelingRunningJobTakesNoEffectUntilJobFinishes() throws Exception {
    dispatcher.setMaxRequests(1);
    Call c1 = client.newCall(newRequest("http://a/1", "tag1"));
    Call c2 = client.newCall(newRequest("http://a/2"));
    c1.enqueue(callback);
    c2.enqueue(callback);
    c1.cancel();
    executor.assertJobs("http://a/1");
    executor.finishJob("http://a/1");
    executor.assertJobs("http://a/2");
  }

  @Test public void asyncCallAccessors() throws Exception {
    dispatcher.setMaxRequests(3);
    Call a1 = client.newCall(newRequest("http://a/1"));
    Call a2 = client.newCall(newRequest("http://a/2"));
    Call a3 = client.newCall(newRequest("http://a/3"));
    Call a4 = client.newCall(newRequest("http://a/4"));
    Call a5 = client.newCall(newRequest("http://a/5"));
    a1.enqueue(callback);
    a2.enqueue(callback);
    a3.enqueue(callback);
    a4.enqueue(callback);
    a5.enqueue(callback);
    assertEquals(3, dispatcher.runningCallsCount());
    assertEquals(2, dispatcher.queuedCallsCount());
    assertEquals(set(a1, a2, a3), set(dispatcher.runningCalls()));
    assertEquals(set(a4, a5), set(dispatcher.queuedCalls()));
  }

  @Test public void synchronousCallAccessors() throws Exception {
    final CountDownLatch ready = new CountDownLatch(2);
    final CountDownLatch waiting = new CountDownLatch(1);
    client = client.newBuilder()
        .addInterceptor(
            new Interceptor() {
              @Override public Response intercept(Chain chain) throws IOException {
                try {
                  ready.countDown();
                  waiting.await();
                } catch (InterruptedException e) {
                  throw new AssertionError();
                }
                throw new IOException();
              }
            })
        .build();

    Call a1 = client.newCall(newRequest("http://a/1"));
    Call a2 = client.newCall(newRequest("http://a/2"));
    Call a3 = client.newCall(newRequest("http://a/3"));
    Call a4 = client.newCall(newRequest("http://a/4"));
    Thread t1 = makeSynchronousCall(a1);
    Thread t2 = makeSynchronousCall(a2);

    // We created 4 calls and started 2 of them. That's 2 running calls and 0 queued.
    ready.await();
    assertEquals(2, dispatcher.runningCallsCount());
    assertEquals(0, dispatcher.queuedCallsCount());
    assertEquals(set(a1, a2), set(dispatcher.runningCalls()));
    assertEquals(Collections.emptyList(), dispatcher.queuedCalls());

    // Cancel some calls. That doesn't impact running or queued.
    a2.cancel();
    a3.cancel();
    assertEquals(set(a1, a2), set(dispatcher.runningCalls()));
    assertEquals(Collections.emptyList(), dispatcher.queuedCalls());

    // Let the calls finish.
    waiting.countDown();
    t1.join();
    t2.join();

    // Now we should have 0 running calls and 0 queued calls.
    assertEquals(0, dispatcher.runningCallsCount());
    assertEquals(0, dispatcher.queuedCallsCount());
    assertEquals(Collections.emptyList(), dispatcher.runningCalls());
    assertEquals(Collections.emptyList(), dispatcher.queuedCalls());

    assertTrue(a1.isExecuted());
    assertFalse(a1.isCanceled());

    assertTrue(a2.isExecuted());
    assertTrue(a2.isCanceled());

    assertFalse(a3.isExecuted());
    assertTrue(a3.isCanceled());

    assertFalse(a4.isExecuted());
    assertFalse(a4.isCanceled());
  }

  @Test public void idleCallbackInvokedWhenIdle() throws InterruptedException {
    final AtomicBoolean idle = new AtomicBoolean();
    dispatcher.setIdleCallback(new Runnable() {
      @Override public void run() {
        idle.set(true);
      }
    });

    client.newCall(newRequest("http://a/1")).enqueue(callback);
    client.newCall(newRequest("http://a/2")).enqueue(callback);
    executor.finishJob("http://a/1");
    assertFalse(idle.get());

    final CountDownLatch ready = new CountDownLatch(1);
    final CountDownLatch proceed = new CountDownLatch(1);
    client = client.newBuilder()
        .addInterceptor(new Interceptor() {
          @Override public Response intercept(Chain chain) throws IOException {
            ready.countDown();
            try {
              proceed.await(5, SECONDS);
            } catch (InterruptedException e) {
              throw new RuntimeException(e);
            }
            return chain.proceed(chain.request());
          }
        })
        .build();

    Thread t1 = makeSynchronousCall(client.newCall(newRequest("http://a/3")));
    ready.await(5, SECONDS);
    executor.finishJob("http://a/2");
    assertFalse(idle.get());

    proceed.countDown();
    t1.join();
    assertTrue(idle.get());
  }

  private <T> Set<T> set(T... values) {
    return set(Arrays.asList(values));
  }

  private <T> Set<T> set(List<T> list) {
    return new LinkedHashSet<>(list);
  }

  private Thread makeSynchronousCall(final Call call) {
    Thread thread = new Thread() {
      @Override public void run() {
        try {
          call.execute();
          throw new AssertionError();
        } catch (IOException expected) {
        }
      }
    };
    thread.start();
    return thread;
  }

  class RecordingExecutor extends AbstractExecutorService {
    private List<AsyncCall> calls = new ArrayList<>();

    @Override public void execute(Runnable command) {
      calls.add((AsyncCall) command);
    }

    public void assertJobs(String... expectedUrls) {
      List<String> actualUrls = new ArrayList<>();
      for (AsyncCall call : calls) {
        actualUrls.add(call.request().url().toString());
      }
      assertEquals(Arrays.asList(expectedUrls), actualUrls);
    }

    public void finishJob(String url) {
      for (Iterator<AsyncCall> i = calls.iterator(); i.hasNext(); ) {
        AsyncCall call = i.next();
        if (call.request().url().toString().equals(url)) {
          i.remove();
          dispatcher.finished(call);
          return;
        }
      }
      throw new AssertionError("No such job: " + url);
    }

    @Override public void shutdown() {
      throw new UnsupportedOperationException();
    }

    @Override public List<Runnable> shutdownNow() {
      throw new UnsupportedOperationException();
    }

    @Override public boolean isShutdown() {
      throw new UnsupportedOperationException();
    }

    @Override public boolean isTerminated() {
      throw new UnsupportedOperationException();
    }

    @Override public boolean awaitTermination(long timeout, TimeUnit unit)
        throws InterruptedException {
      throw new UnsupportedOperationException();
    }
  }

  private Request newRequest(String url) {
    return new Request.Builder().url(url).build();
  }

  private Request newRequest(String url, String tag) {
    return new Request.Builder().url(url).tag(tag).build();
  }
}
