package edu.illinois.cs.cs124.ay2024.mp.test;

import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom;
import static com.google.common.truth.Truth.assertWithMessage;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.Data.OBJECT_MAPPER;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.Data.RSODATA;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.Data.SUMMARIES;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.Data.getShuffledSummaries;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.HTTP.getAPIClient;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.HTTP.testClient;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.HTTP.testServerGet;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.HTTP.testServerGetTimed;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.HTTP.testServerPost;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.TestHelpers.checkServerDesign;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.TestHelpers.configureLogging;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.TestHelpers.pause;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.TestHelpers.startActivity;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.TestHelpers.trimmedMean;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.Views.isChecked;
import static edu.illinois.cs.cs124.ay2024.mp.test.helpers.Views.setChecked;

import android.content.Intent;
import android.widget.ToggleButton;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import edu.illinois.cs.cs124.ay2024.mp.activities.RSOActivity;
import edu.illinois.cs.cs124.ay2024.mp.models.Favorite;
import edu.illinois.cs.cs124.ay2024.mp.models.Summary;
import edu.illinois.cs.cs124.ay2024.mp.network.Client;
import edu.illinois.cs.cs124.ay2024.mp.network.Server;
import edu.illinois.cs.cs124.ay2024.mp.test.helpers.HTTP;
import edu.illinois.cs.cs124.ay2024.mp.test.helpers.JSONReadCountSecurityManager;
import edu.illinois.cs.cs125.gradlegrader.annotations.Graded;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Random;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.MethodSorters;
import org.robolectric.annotation.LooperMode;
import org.robolectric.annotation.experimental.LazyApplication;

/*
 * This is the MP3 test suite.
 * The code below is used to evaluate your app during testing, local grading, and official grading.
 * You may not understand all of the code below, but you'll need to have some understanding of how
 * it works so that you can determine what is wrong with your app and what you need to fix.
 *
 * ALL CHANGES TO THIS FILE WILL BE OVERWRITTEN DURING OFFICIAL GRADING.
 * You can and should modify the code below if it is useful during your own local testing,
 * but any changes you make will be discarded during official grading.
 * The local grader will not run if the test suites have been modified, so you'll need to undo any
 * local changes before you run the grader.
 *
 * Note that this means that you should not fix problems with the app by modifying the test suites.
 * The test suites are always considered to be correct.
 *
 * Our test suites are broken into two parts.
 * The unit tests are tests that we can perform without running your app.
 * They test things like whether a method works properly or the behavior of your API server.
 * Unit tests are usually fairly fast.
 *
 * The integration tests are tests that require simulating your app.
 * This allows us to test things like your API client, and higher-level aspects of your app's
 * behavior, such as whether it displays the right thing on the display.
 * Because integration tests require simulating your app, they run more slowly.
 *
 * The MP3 test suite includes no ungraded tests.
 * These tests are fairly idiomatic, in that they resemble tests you might write for an actual
 * Android programming project.
 * Not entirely though.
 */

@RunWith(AndroidJUnit4.class)
@LooperMode(LooperMode.Mode.PAUSED)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public final class MP3Test {
  // Extra time allowed by GET /favorite compared to baseline
  private static final double GET_METHOD_EXTRA_TIME = 1.6;

  // Test the POST and GET /favorite server routes
  @SuppressWarnings("SpellCheckingInspection")
  @Test(timeout = 60000L)
  @Graded(points = 30, friendlyName = "Server GET and POST /favorite (Unit)")
  @LazyApplication(LazyApplication.LazyLoad.ON)
  public void test0_ServerGETAndPOSTFavorite() throws IOException {
    // Get a shuffled list of summaries
    Random random = new Random(12431);
    List<Summary> trimmedSummaries = getShuffledSummaries(12431, 128);

    // Perform initial GET /favorite requests
    for (Summary summary : trimmedSummaries) {
      // Perform initial GET
      Favorite favorite = testServerGet("/favorite/" + summary.getId(), Favorite.class);
      // No RSOs should be a favorite at this point
      assertWithMessage("Incorrect favorite value for RSO")
          .that(favorite.getFavorite())
          .isEqualTo(false);
    }

    // Perform POST /favorite requests to change favorite status

    // Map to store the favorite status set during the next round
    Map<String, Boolean> favorites = new HashMap<>();
    // Reshuffle the list of summaries
    Collections.shuffle(trimmedSummaries, random);

    for (Summary summary : trimmedSummaries) {
      // POST to change favorite value
      boolean isFavorite = random.nextBoolean();

      // Construct POST favorite body
      ObjectNode newFavorite = OBJECT_MAPPER.createObjectNode();
      newFavorite.set("id", OBJECT_MAPPER.convertValue(summary.getId(), JsonNode.class));
      newFavorite.set("favorite", OBJECT_MAPPER.convertValue(isFavorite, JsonNode.class));

      // POST to change the favorite status accordingly
      Favorite favorite = testServerPost("/favorite", newFavorite, Favorite.class);
      // Ensure the result is what we expect
      assertWithMessage("Incorrect value from favorite POST")
          .that(favorite.getFavorite())
          .isEqualTo(isFavorite);

      // Save favorite value for next stage
      favorites.put(summary.getId(), isFavorite);
    }

    // Save response times for comparison
    List<Long> baseResponseTimes = new ArrayList<>();
    List<Long> getResponseTimes = new ArrayList<>();

    // Second route of GET /favorite requests to ensure favorites are saved
    Collections.shuffle(trimmedSummaries, random);
    for (Summary summary : trimmedSummaries) {
      // Time the index route for comparison
      HTTP.TimedResponse<Summary> baseResult = testServerGetTimed("/");
      baseResponseTimes.add(baseResult.getResponseTime().toNanos());

      // Retrieve saved favorite
      boolean savedFavorite = Objects.requireNonNull(favorites.get(summary.getId()));

      // Final GET
      HTTP.TimedResponse<Favorite> favoriteResult =
          testServerGetTimed("/favorite/" + summary.getId(), Favorite.class);
      // Ensure the result is what we expect
      assertWithMessage("Incorrect favorite value for RSO")
          .that(favoriteResult.getResponse().getFavorite())
          .isEqualTo(savedFavorite);
      getResponseTimes.add(favoriteResult.getResponseTime().toNanos());
    }

    // Check for slow server GETs potentially caused by unnecessary looping or parsing
    double averageBase = trimmedMean(baseResponseTimes, 0.1);
    double averageResponse = trimmedMean(getResponseTimes, 0.1);
    assertWithMessage("Server GET /favorite/ is too slow")
        .that(averageResponse)
        .isLessThan(averageBase * GET_METHOD_EXTRA_TIME);

    // Test bad requests

    // Non-existent RSO GET
    testServerGet("/favorite/XgRG0-qzi7cNaYPtF8lI1m62tEO/", HttpURLConnection.HTTP_NOT_FOUND);

    // Bad URL GET
    testServerGet("/favorites/8OozGTNQpaETr0sAB4UVt_ee2qk", HttpURLConnection.HTTP_NOT_FOUND);

    // Non-existent RSO POST
    ObjectNode nonexistentRSO = OBJECT_MAPPER.createObjectNode();
    //noinspection SpellCheckingInspection
    nonexistentRSO.set(
        "id", OBJECT_MAPPER.convertValue("ejEQxt9QtbWpjqBIvMt4XaxDDdW", JsonNode.class));
    nonexistentRSO.set("favorite", OBJECT_MAPPER.convertValue(false, JsonNode.class));
    testServerPost("/favorite", nonexistentRSO, HttpURLConnection.HTTP_NOT_FOUND);

    // Bad URL POST
    testServerPost("/favorites/", nonexistentRSO, HttpURLConnection.HTTP_NOT_FOUND);

    // Bad body POST
    ObjectNode badBody = OBJECT_MAPPER.createObjectNode();
    badBody.set("id", OBJECT_MAPPER.convertValue("8OozGTNQpaETr0sAB4UVt_ee2qk", JsonNode.class));
    badBody.set("isFavorite", OBJECT_MAPPER.convertValue(false, JsonNode.class));
    testServerPost("/favorite", badBody, HttpURLConnection.HTTP_BAD_REQUEST);
  }

  // Test the client getFavorite and setFavorite methods
  @Test(timeout = 30000L)
  @Graded(points = 20, friendlyName = "Client getFavorite and setFavorite (Integration)")
  @LazyApplication(LazyApplication.LazyLoad.ON)
  public void test1_ClientGetAndSetFavorite() throws Exception {
    // API client for testing
    Client apiClient = getAPIClient();

    // Get a shuffled list of summaries
    Random random = new Random(12433);
    List<Summary> trimmedSummaries = getShuffledSummaries(12433, 128);
    // Map to store the favorite status set by Client.setFavorite
    Map<String, Boolean> favorites = new HashMap<>();

    // Go through all RSOs twice
    for (int repeat = 0; repeat < 2; repeat++) {
      for (Summary summary : trimmedSummaries) {
        // Randomly either getFavorite or setFavorite
        boolean currentFavorite;
        if (random.nextBoolean()) {
          currentFavorite =
              testClient((callback) -> apiClient.getFavorite(summary.getId(), callback));
        } else {
          boolean isFavorite = random.nextBoolean();
          favorites.put(summary.getId(), isFavorite);
          currentFavorite =
              testClient(
                  (callback) -> apiClient.setFavorite(summary.getId(), isFavorite, callback));
        }

        // Check against the expected value
        boolean expectedFavorite =
            Objects.requireNonNull(favorites.getOrDefault(summary.getId(), false));
        assertWithMessage("Incorrect favorite value")
            .that(currentFavorite)
            .isEqualTo(expectedFavorite);
      }
    }

    // Test bad Client.getFavorite call
    try {
      boolean ignored =
          testClient((callback) -> apiClient.getFavorite("Lr363RRTGsnNHO3EO37VkKOk65a", callback));
      assertWithMessage("Client GET /favorite for non-existent RSO should throw").fail();
    } catch (Exception ignored) {
    }
  }

  // Helper method for the favorite button UI test
  private void favoriteButtonHelper(
      Summary summary, boolean currentFavorite, boolean nextFavorite) {

    // Prepare the Intent to start the RSOActivity
    Intent intent = new Intent(ApplicationProvider.getApplicationContext(), RSOActivity.class);
    intent.putExtra("id", summary.getId());

    // Start the RSOActivity
    startActivity(
        intent,
        activity -> {
          pause();
          // Check that the initial favorite status is correct, change it,
          // and then verify the change
          onView(isAssignableFrom(ToggleButton.class))
              .check(isChecked(currentFavorite))
              .perform(setChecked(nextFavorite))
              .check(isChecked(nextFavorite));
        });
  }

  // Test the favorite button
  @Test(timeout = 30000L)
  @Graded(points = 30, friendlyName = "Favorite Button (Integration)")
  @LazyApplication(LazyApplication.LazyLoad.ON)
  public void test2_FavoriteButton() {
    // Get a shuffled small list of summaries
    Random random = new Random(12434);
    List<Summary> trimmedSummaries = getShuffledSummaries(12434, 2);
    // Map to store our expected favorite results
    Map<String, Boolean> favorites = new HashMap<>();

    // Go through each summary four times, either setting or clearing its favorite status
    for (int repeat = 0; repeat < 4; repeat++) {
      Collections.shuffle(trimmedSummaries, random);
      for (Summary summary : trimmedSummaries) {
        boolean currentFavorite =
            Objects.requireNonNull(favorites.getOrDefault(summary.getId(), false));
        boolean nextFavorite = random.nextBoolean();
        favorites.put(summary.getId(), nextFavorite);
        favoriteButtonHelper(summary, currentFavorite, nextFavorite);
      }
    }
  }

  // Security manager used to count access to rsos.json
  private static final JSONReadCountSecurityManager JSON_READ_COUNT_SECURITY_MANAGER =
      new JSONReadCountSecurityManager();

  // Run once before any test in this suite is started
  @BeforeClass
  public static void beforeClass() {
    // Check Server.java for publicly visible members
    checkServerDesign();
    // Set up logging so that you can see log output during testing
    configureLogging();
    // Force loads to perform initialization before we start counting
    RSODATA.size();
    SUMMARIES.size();
    // Install our security manager that allows counting access to rsos.json
    System.setSecurityManager(JSON_READ_COUNT_SECURITY_MANAGER);
    // Start the API server
    Server.start();
  }

  // Run once after all tests in this suite are completed
  @AfterClass
  public static void afterClass() {
    // Remove the custom security manager
    System.setSecurityManager(null);
  }

  // Run before each test in the suite starts
  @Before
  public void beforeTest() {
    // Randomly set several RSOs to favorites before server reset to defend against client-side
    // caching
    Client apiClient = getAPIClient();

    List<Summary> summarySubset = getShuffledSummaries(new Random().nextInt(), 16);
    for (Summary summary : summarySubset) {
      try {
        boolean ignored =
            testClient((callback) -> apiClient.setFavorite(summary.getId(), true, callback));
      } catch (Exception ignored) {
      }
    }

    // Reset the server between tests
    try {
      Server.reset();
    } catch (Exception ignored) {
    }
    // Check for extra reads from rsos.json
    JSON_READ_COUNT_SECURITY_MANAGER.checkCount();
  }

  // Run after each test completes
  @After
  public void afterTest() {
    // Check for extra reads from rsos.json
    JSON_READ_COUNT_SECURITY_MANAGER.checkCount();
  }
}

// md5: 6e15839dcabf1a3b57629780365db881 // DO NOT REMOVE THIS LINE
