/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * 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 androidx.test.espresso.remote;

import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static androidx.test.espresso.remote.EspressoRemote.BUNDLE_KEY_PROTO;
import static junit.framework.Assert.assertTrue;
import static junit.framework.Assert.fail;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Message;
import android.os.Messenger;
import android.view.View;
import androidx.test.espresso.NoMatchingViewException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.ViewAssertion;
import androidx.test.espresso.matcher.RootMatchers;
import androidx.test.espresso.proto.UiInteraction.InteractionResultProto;
import androidx.test.espresso.remote.EspressoRemote.RemoteInteractionStrategy;
import androidx.test.espresso.util.concurrent.ListeningExecutorService;
import androidx.test.espresso.util.concurrent.ThreadFactoryBuilder;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.SmallTest;
import androidx.test.internal.runner.InstrumentationConnection;
import androidx.test.internal.util.ParcelableIBinder;
import com.google.common.util.concurrent.ListenableFuture;
import java.io.UnsupportedEncodingException;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.hamcrest.Matcher;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;

/**
 * Tests exercise {@link EspressoRemote}'s logic of remote Espresso instance discovery and
 * communication
 */
@RunWith(AndroidJUnit4.class)
@SmallTest
public class EspressoRemoteTest {
  @Mock private InstrumentationConnection mockedInstrumentation;
  private ListeningExecutorService remoteExecutor;

  @Before
  public void setup() {
    MockitoAnnotations.initMocks(this);
    remoteExecutor =
        new ListeningExecutorService(
            new ThreadPoolExecutor(
                0 /*corePoolSize*/,
                5 /*maximumPoolSize*/,
                10,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(),
                new ThreadFactoryBuilder().setNameFormat("Espresso Remote #%d").build()));
  }

  @Test
  public void verifyInitTerminateRegistersUnregistersWithInstrumentation()
      throws InterruptedException {
    final EspressoRemote espressoRemote = new EspressoRemote(mockedInstrumentation);
    espressoRemote.init();
    Messenger messenger = espressoRemote.incomingHandler.messengerHandler;
    verify(mockedInstrumentation).registerClient(EspressoRemote.TYPE, messenger);
    espressoRemote.terminate();
    // Termination happens synchronously on the handler thread. At the end of the termination
    // method handler thread is going to quit so there is no good way to synchronise this test other
    // than sleep
    Thread.sleep(100);
    verify(mockedInstrumentation).unregisterClient(EspressoRemote.TYPE, messenger);
  }

  @Test
  public void verifyMultipleInitsAreIgnored() {
    final EspressoRemote espressoRemote = new EspressoRemote(mockedInstrumentation);
    espressoRemote.init();
    espressoRemote.init();
    espressoRemote.init();
    Messenger messenger = espressoRemote.incomingHandler.messengerHandler;
    verify(mockedInstrumentation).registerClient(EspressoRemote.TYPE, messenger);
  }

  @Test
  public void verifyValidRequestResponseToRemoteEspresso() throws InterruptedException {
    final EspressoRemote espressoRemote = new EspressoRemote(mockedInstrumentation);
    espressoRemote.init();

    // create a tmp handler to receive a response from the EspressoRemote under test
    HandlerThread handlerThread = new HandlerThread("OtherEspresso");
    handlerThread.start();
    Handler handler =
        new Handler(handlerThread.getLooper()) {
          @Override
          public void handleMessage(Message msg) {
            // verify that other remote espresso received a request
            assertEquals(EspressoRemote.MSG_HANDLE_ESPRESSO_REQUEST, msg.what);
            // TODO: remove when actual proto is ready
            try {
              assertEquals(
                  "TBDProto", new String(msg.getData().getByteArray(BUNDLE_KEY_PROTO), "UTF-8"));

            } catch (UnsupportedEncodingException e) {
              fail("UnsupportedEncodingException was thrown");
            }

            // mimic a response
            msg.what = EspressoRemote.MSG_HANDLE_ESPRESSO_RESPONSE;
            Bundle resultData = msg.getData();
            resultData.putByteArray(
                BUNDLE_KEY_PROTO,
                InteractionResultProto.newBuilder().setOk(true).build().toByteArray());
            msg.setData(resultData);
            espressoRemote.incomingHandler.handleMessage(msg);

            getLooper().quit();
          }
        };
    // create a tmp messenger to represent "other" remote Espresso
    Messenger otherEspressoMessenger = new Messenger(handler);
    Set<Messenger> clients = new HashSet<>();
    clients.add(otherEspressoMessenger);
    when(mockedInstrumentation.getClientsForType(EspressoRemote.TYPE)).thenReturn(clients);

    espressoRemote.initiateRemoteCall("TBDProto".getBytes(), null);
  }

  @Test
  public void verifyExceptionResultReturnedBackToTheCaller() {
    final EspressoRemote espressoRemote = new EspressoRemote(mockedInstrumentation);
    espressoRemote.init();

    // create a tmp handler to receive a response from the EspressoRemote under test
    HandlerThread handlerThread = new HandlerThread("OtherEspresso");
    handlerThread.start();
    Handler handler =
        new Handler(handlerThread.getLooper()) {
          @Override
          public void handleMessage(Message msg) {
            // verify that other remote espresso received a request
            assertEquals(EspressoRemote.MSG_HANDLE_ESPRESSO_REQUEST, msg.what);
            espressoRemote.incomingHandler.handleMessage(msg);

            getLooper().quit();
          }
        };
    // create a tmp messenger to represent "other" remote Espresso
    Messenger otherEspressoMessenger = new Messenger(handler);
    Set<Messenger> clients = new HashSet<>();
    clients.add(otherEspressoMessenger);
    when(mockedInstrumentation.getClientsForType(EspressoRemote.TYPE)).thenReturn(clients);

    try {
      espressoRemote.initiateRemoteCall(null, null);
      fail("Expected RemoteEspressoException to be thrown");
    } catch (RemoteEspressoException e) {
      // expected
    }
  }

  @Test
  public void verifyInterruptedRequest_followsUpWithEmptyRequest() {
    final EspressoRemote espressoRemote = new EspressoRemote(mockedInstrumentation);
    espressoRemote.init();

    final CountDownLatch espressoRequestLatch = new CountDownLatch(1);
    final CountDownLatch emptyRequestLatch = new CountDownLatch(1);

    // create a tmp handler to receive a response from the EspressoRemote under test
    final HandlerThread handlerThread = new HandlerThread("OtherEspresso");
    handlerThread.start();
    Handler handler =
        new Handler(handlerThread.getLooper()) {
          @Override
          public void handleMessage(Message msg) {
            switch (msg.what) {
              case EspressoRemote.MSG_HANDLE_ESPRESSO_REQUEST:
                espressoRequestLatch.countDown();
                break;
              case EspressoRemote.MSG_HANDLE_EMPTY_REQUEST:
                emptyRequestLatch.countDown();
                break;
              default:
                super.handleMessage(msg);
            }
          }
        };

    // create a tmp messenger to represent "other" remote Espresso
    Messenger otherEspressoMessenger = new Messenger(handler);
    Set<Messenger> clients = new HashSet<>();
    clients.add(otherEspressoMessenger);
    clients.add(espressoRemote.incomingHandler.messengerHandler);
    when(mockedInstrumentation.getClientsForType(EspressoRemote.TYPE)).thenReturn(clients);

    // send out an interaction request to remote espresso
    ListenableFuture<Void> future =
        remoteExecutor.submit(
            espressoRemote.createRemoteCheckCallable(
                RootMatchers.DEFAULT, withId(123), null, matches(withText(is("test")))));

    try {
      // wait until remote Espresso receives an interaction request
      assertTrue(espressoRequestLatch.await(200, TimeUnit.MILLISECONDS));
      // interrupt the remote interaction request
      future.cancel(true);
      // ensure extra empty message was sent out to flush out the remote instance handler queue
      assertTrue(emptyRequestLatch.await(200, TimeUnit.MILLISECONDS));
    } catch (InterruptedException e) {
      fail("Unexpected InterruptedException");
    }

    // clean up
    handlerThread.getLooper().quit();
  }

  @Test
  public void remoteInteractionStrategy_addsViewActionBinders_fromBundle() {
    Matcher viewMatcherMock = Mockito.mock(Matcher.class);
    Matcher rootMatcherMock = Mockito.mock(Matcher.class);
    IBinder binderMock = Mockito.mock(IBinder.class);
    Bindable bindableMock = Mockito.mock(Bindable.class);
    String bindableViewActionId = BindableViewAction.class.getSimpleName();
    when(bindableMock.getId()).thenReturn(bindableViewActionId);
    ViewAction bindableViewAction = new BindableViewAction(bindableMock);

    InteractionRequest interactionRequest =
        new InteractionRequest(rootMatcherMock, viewMatcherMock, bindableViewAction, null);

    Bundle bundle = new Bundle();
    bundle.putParcelable(bindableMock.getId(), new ParcelableIBinder(binderMock));

    RemoteInteractionStrategy.from(interactionRequest, bundle);

    verify(bindableMock).setIBinder(binderMock);
  }

  @Test
  public void remoteInteractionStrategy_addsViewAssertionBinders_fromBundle() {
    Matcher viewMatcherMock = Mockito.mock(Matcher.class);
    Matcher rootMatcherMock = Mockito.mock(Matcher.class);
    IBinder binderMock = Mockito.mock(IBinder.class);
    Bindable bindableMock = Mockito.mock(Bindable.class);
    String bindableViewAssertionId = BindableViewAssertion.class.getSimpleName();
    when(bindableMock.getId()).thenReturn(bindableViewAssertionId);
    ViewAssertion bindableViewAssertion = new BindableViewAssertion(bindableMock);

    InteractionRequest interactionRequest =
        new InteractionRequest(rootMatcherMock, viewMatcherMock, null, bindableViewAssertion);

    Bundle bundle = new Bundle();
    bundle.putParcelable(bindableMock.getId(), new ParcelableIBinder(binderMock));
    RemoteInteractionStrategy.from(interactionRequest, bundle);

    verify(bindableMock).setIBinder(binderMock);
  }

  private static final class BindableViewAction implements ViewAction, Bindable {

    private final Bindable bindableMock;

    BindableViewAction(Bindable bindableMock) {
      this.bindableMock = bindableMock;
    }

    @Override
    public Matcher<View> getConstraints() {
      return null;
    }

    @Override
    public String getDescription() {
      return "";
    }

    @Override
    public void perform(UiController uiController, View view) {
      // do nothing
    }

    @Override
    public String getId() {
      return bindableMock.getId();
    }

    @Override
    public IBinder getIBinder() {
      return bindableMock.getIBinder();
    }

    @Override
    public void setIBinder(IBinder binder) {
      this.bindableMock.setIBinder(binder);
    }
  }

  private static final class BindableViewAssertion implements ViewAssertion, Bindable {

    private final Bindable bindableMock;

    BindableViewAssertion(Bindable bindableMock) {
      this.bindableMock = bindableMock;
    }

    @Override
    public String getId() {
      return bindableMock.getId();
    }

    @Override
    public IBinder getIBinder() {
      return bindableMock.getIBinder();
    }

    @Override
    public void setIBinder(IBinder binder) {
      this.bindableMock.setIBinder(binder);
    }

    @Override
    public void check(View view, NoMatchingViewException noViewFoundException) {
      // do nothing
    }
  }
}
