/*
 * Copyright 2015-2017 Spotify AB
 * Copyright 2016-2019 The Last Pickle Ltd
 *
 * 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 io.cassandrareaper.acceptance;

import io.cassandrareaper.AppContext;
import io.cassandrareaper.SimpleReaperClient;
import io.cassandrareaper.core.DiagEventSubscription;
import io.cassandrareaper.core.DroppedMessages;
import io.cassandrareaper.core.MetricsHistogram;
import io.cassandrareaper.core.RepairRun;
import io.cassandrareaper.core.RepairSegment;
import io.cassandrareaper.core.Snapshot;
import io.cassandrareaper.core.ThreadPoolStat;
import io.cassandrareaper.resources.view.RepairRunStatus;
import io.cassandrareaper.resources.view.RepairScheduleStatus;
import io.cassandrareaper.service.RepairRunService;
import io.cassandrareaper.storage.CassandraStorage;
import io.cassandrareaper.storage.PostgresStorage;
import io.cassandrareaper.storage.postgresql.DiagEventSubscriptionMapper;

import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;

import javax.ws.rs.core.Response;

import com.datastax.driver.core.Cluster;
import com.datastax.driver.core.Host;
import com.datastax.driver.core.Session;
import com.datastax.driver.core.SocketOptions;
import com.datastax.driver.core.VersionNumber;
import com.datastax.driver.core.exceptions.AlreadyExistsException;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import cucumber.api.java.en.And;
import cucumber.api.java.en.Given;
import cucumber.api.java.en.Then;
import cucumber.api.java.en.When;
import org.apache.commons.lang3.StringUtils;
import org.assertj.core.api.Assertions;
import org.awaitility.Duration;
import org.awaitility.core.ConditionTimeoutException;
import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static java.util.concurrent.TimeUnit.MINUTES;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.awaitility.Awaitility.await;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

/**
 * Basic acceptance test (Cucumber) steps.
 */
@SuppressWarnings("ResultOfMethodCallIgnored")
public final class BasicSteps {

  private static final Logger LOG = LoggerFactory.getLogger(BasicSteps.class);
  private static final Optional<Map<String, String>> EMPTY_PARAMS = Optional.empty();
  private static final Duration POLL_INTERVAL = Duration.TWO_SECONDS;

  private static final List<ReaperTestJettyRunner> RUNNERS = new CopyOnWriteArrayList<>();
  private static final List<SimpleReaperClient> CLIENTS = new CopyOnWriteArrayList<>();
  private static final Random RAND = new Random(System.nanoTime());
  private static final AtomicReference<Upgradable> TEST_INSTANCE = new AtomicReference<>();

  private static final Map<String,String> EVENT_TYPES = ImmutableMap.<String,String>builder()
      .put("AuditEvent", "org.apache.cassandra.audit.AuditEvent")
      .put("BootstrapEvent", "org.apache.cassandra.dht.BootstrapEvent")
      .put("GossiperEvent", "org.apache.cassandra.gms.GossiperEvent")
      .put("HintEvent", "org.apache.cassandra.hints.HintEvent")
      .put("HintsServiceEvent", "org.apache.cassandra.hints.HintsServiceEvent")
      .put("ReadRepairEvent", "org.apache.cassandra.service.reads.repair.ReadRepairEvent")
      .put("SchemaEvent", "org.apache.cassandra.schema.SchemaEvent")
      .build();

  private Optional<String> reaperVersion = Optional.empty();
  private Response lastResponse;
  private TestContext testContext;

  public static synchronized void addReaperRunner(ReaperTestJettyRunner runner) {
    if (!CLIENTS.isEmpty()) {
      Preconditions.checkState(isInstanceOfDistributedStorage(runner.runnerInstance.getContextStorageClassname()));
      RUNNERS.stream()
          .forEach(r ->
              Preconditions.checkState(
                  isInstanceOfDistributedStorage(runner.runnerInstance.getContextStorageClassname())
              ));
    }
    RUNNERS.add(runner);
    CLIENTS.add(runner.getClient());
  }

  public static synchronized void removeReaperRunner(ReaperTestJettyRunner runner) {
    CLIENTS.remove(runner.getClient());
    RUNNERS.remove(runner);
  }

  static void setup(Upgradable testInstance) {
    Preconditions.checkState(null == TEST_INSTANCE.get());
    TEST_INSTANCE.set(testInstance);
  }

  private static void callAndExpect(
      String httpMethod,
      String callPath,
      Optional<Map<String, String>> params,
      Optional<String> expectedDataInResponseData,
      Response.Status... expectedStatuses) {

    RUNNERS.parallelStream().forEach(runner -> {
      Response response = runner.callReaper(httpMethod, callPath, params);
      String responseEntity = response.readEntity(String.class);

      Assertions
          .assertThat(Arrays.asList(expectedStatuses).stream().map(Response.Status::getStatusCode))
          .withFailMessage(responseEntity)
          .contains(response.getStatus())
          .isNotEmpty();

      if (1 == RUNNERS.size() && expectedStatuses[0].getStatusCode() != response.getStatus()) {
        // we can't fail on this because the jersey client sometimes sends
        // duplicate http requests and the wrong request responds firs
        LOG.error(
            "AssertionError: expected: {} but was: {}",
            expectedStatuses[0].getStatusCode(), response.getStatus());
      }

      if (expectedStatuses[0].getStatusCode() == response.getStatus()) {
        if (expectedDataInResponseData.isPresent()) {
          if (Sets.newHashSet("PUT", "POST").contains(httpMethod)) {
            // rest command requests should not response with bodies, follow the location to GET that
            Assertions.assertThat(responseEntity).isEmpty();
            // follow to new location (to GET resource)
            response = runner.callReaper("GET", response.getLocation().toString(), Optional.empty());
            responseEntity = response.readEntity(String.class);
          } else if ("DELETE".equals(httpMethod)) {
            throw new IllegalArgumentException("tests can't expect response body from DELETE request");
          }
          assertTrue(
              "expected data not found from the response: " + expectedDataInResponseData.get(),
              0 != responseEntity.length() && responseEntity.contains(expectedDataInResponseData.get()));

          LOG.debug("Data \"" + expectedDataInResponseData.get() + "\" was found from response data");
        }
      }
    });
  }

  @Given("^cluster seed host \"([^\"]*)\" points to cluster with name \"([^\"]*)\"$")
  public void cluster_seed_host_points_to_cluster_with_name(String seedHost, String clusterName) throws Throwable {
    synchronized (BasicSteps.class) {
      TestContext.SEED_HOST = seedHost + '@' + clusterName;
      TestContext.addSeedHostToClusterMapping(seedHost, clusterName);
    }
  }

  @Given("^cluster \"([^\"]*)\" has keyspace \"([^\"]*)\" with tables \"([^\"]*)\"$")
  public void ccm_cluster_has_keyspace_with_tables(
      String clusterName,
      String keyspace,
      String tablesListStr) throws Throwable {

    synchronized (BasicSteps.class) {
      Set<String> tables = Sets.newHashSet(RepairRunService.COMMA_SEPARATED_LIST_SPLITTER.split(tablesListStr));
      createKeyspace(keyspace);
      tables.stream().forEach(tableName -> createTable(keyspace, tableName));
      TestContext.addClusterInfo(clusterName, keyspace, tables);
    }
  }

  @Given("^that reaper ([^\"]*) is running$")
  public void start_reaper(String version) throws Throwable {
    synchronized (BasicSteps.class) {
      testContext = new TestContext();
      Optional<String> newVersion = version.trim().isEmpty() ? Optional.empty() : Optional.of(version);
      if (RUNNERS.isEmpty() || !newVersion.equals(reaperVersion)) {
        if (null == TEST_INSTANCE.get()) {
          throw new AssertionError(
              "Running upgrade tests is not supported with this IT. The test must subclass and implement Upgradable");
        }
        reaperVersion = newVersion;
        TEST_INSTANCE.get().upgradeReaperRunner(reaperVersion);
      }
    }
  }

  @When("^reaper is upgraded to latest$")
  public void upgrade_reaper() throws Throwable {
    synchronized (BasicSteps.class) {
      if (reaperVersion.isPresent()) {
        reaperVersion = Optional.empty();
        TEST_INSTANCE.get().upgradeReaperRunner(Optional.empty());
      }
    }
  }

  @And("^reaper has no cluster with name \"([^\"]*)\" in storage$")
  public void reaper_has_no_cluster_with_name_in_storage(String clusterName) throws Throwable {
    synchronized (BasicSteps.class) {
      callAndExpect("GET", "/cluster/" + clusterName,
          Optional.<Map<String, String>>empty(),
          Optional.<String>empty(), Response.Status.NOT_FOUND);
    }
  }

  @And("^reaper has no cluster in storage$")
  public void reaper_has_no_cluster_in_storage() throws Throwable {
    synchronized (BasicSteps.class) {
      RUNNERS.parallelStream().forEach(runner -> {
        Response response = runner.callReaper("GET", "/cluster/", Optional.<Map<String, String>>empty());
        assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
        String responseData = response.readEntity(String.class);
        Assertions.assertThat(responseData).isNotBlank();
        List<String> clusterNames = SimpleReaperClient.parseClusterNameListJSON(responseData);
        if (!((AppContext) runner.runnerInstance.getContext()).config.isInSidecarMode()) {
          // Sidecar self registers clusters
          assertEquals(0, clusterNames.size());
        }
      });
    }
  }

  @When("^an add-cluster request is made to reaper$")
  public void an_add_cluster_request_is_made_to_reaper() throws Throwable {
    synchronized (BasicSteps.class) {
      RUNNERS.parallelStream().forEach(runner -> {
        Map<String, String> params = Maps.newHashMap();
        params.put("seedHost", TestContext.SEED_HOST);
        params.put("jmxPort", "7100");
        Response response = runner.callReaper("POST", "/cluster", Optional.of(params));
        int responseStatus = response.getStatus();
        String responseEntity = response.readEntity(String.class);

        Assertions.assertThat(
                ImmutableList.of(
                    Response.Status.CREATED.getStatusCode(),
                    Response.Status.NO_CONTENT.getStatusCode(),
                    Response.Status.OK.getStatusCode()))
            .withFailMessage(responseEntity)
            .contains(responseStatus);

        // rest command requests should not response with bodies, follow the location to GET that
        Assertions.assertThat(responseEntity).isEmpty();

        // follow to new location (to GET resource)
        response = runner.callReaper("GET", response.getLocation().toString(), Optional.empty());

        String responseData = response.readEntity(String.class);
        Assertions.assertThat(responseData).isNotBlank();
        Map<String, Object> cluster = SimpleReaperClient.parseClusterStatusJSON(responseData);

        if (Response.Status.CREATED.getStatusCode() == responseStatus
            || ((AppContext) runner.runnerInstance.getContext()).config.isInSidecarMode()) {
          TestContext.TEST_CLUSTER = (String) cluster.get("name");
        }
      });

      callAndExpect(
          "GET",
          "/cluster/" + TestContext.TEST_CLUSTER,
          Optional.<Map<String, String>>empty(),
          Optional.<String>empty(),
          Response.Status.OK);
    }
  }

  @When("^an add-cluster request is made to reaper with authentication$")
  public void an_add_cluster_request_is_made_to_reaper_with_authentication() throws Throwable {
    synchronized (BasicSteps.class) {
      System.setProperty("REAPER_ENCRYPTION_KEY", "reaper");
      RUNNERS.parallelStream().forEach(runner -> {
        Map<String, String> params = Maps.newHashMap();
        params.put("seedHost", TestContext.SEED_HOST);
        params.put("jmxPort", "7100");
        params.put("jmxUsername", "cassandra");
        params.put("jmxPassword", "cassandrapassword");
        Response response = runner.callReaper("POSTFORM", "/cluster/auth", Optional.of(params));
        int responseStatus = response.getStatus();
        String responseEntity = response.readEntity(String.class);

        Assertions.assertThat(
                ImmutableList.of(
                    Response.Status.CREATED.getStatusCode(),
                    Response.Status.NO_CONTENT.getStatusCode(),
                    Response.Status.OK.getStatusCode()))
            .withFailMessage(responseEntity)
            .contains(responseStatus);

        // rest command requests should not response with bodies, follow the location to GET that
        Assertions.assertThat(responseEntity).isEmpty();

        // follow to new location (to GET resource)
        response = runner.callReaper("GET", response.getLocation().toString(), Optional.empty());

        String responseData = response.readEntity(String.class);
        Assertions.assertThat(responseData).isNotBlank();
        Map<String, Object> cluster = SimpleReaperClient.parseClusterStatusJSON(responseData);

        if (Response.Status.CREATED.getStatusCode() == responseStatus
            || ((AppContext) runner.runnerInstance.getContext()).config.isInSidecarMode()) {
          TestContext.TEST_CLUSTER = (String) cluster.get("name");
        }
      });

      callAndExpect(
          "GET",
          "/cluster/" + TestContext.TEST_CLUSTER,
          Optional.<Map<String, String>>empty(),
          Optional.<String>empty(),
          Response.Status.OK);
    }
  }

  @When("^an add-cluster request is made to reaper with authentication and no encryption$")
  public void an_add_cluster_request_is_made_to_reaper_with_authentication_and_no_encryption() throws Throwable {
    synchronized (BasicSteps.class) {
      System.clearProperty("REAPER_ENCRYPTION_KEY");
      RUNNERS.parallelStream().forEach(runner -> {
        Map<String, String> params = Maps.newHashMap();
        params.put("seedHost", TestContext.SEED_HOST);
        params.put("jmxPort", "7100");
        params.put("jmxUsername", "cassandra");
        params.put("jmxPassword", "cassandrapassword");
        Response response = runner.callReaper("POSTFORM", "/cluster/auth", Optional.of(params));
        int responseStatus = response.getStatus();
        String responseEntity = response.readEntity(String.class);

        // This should fail because there's no encryption key in the system env variables
        Assertions.assertThat(
                ImmutableList.of(
                    Response.Status.BAD_REQUEST.getStatusCode(),
                    Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()))
            .withFailMessage(responseEntity)
            .contains(responseStatus);

      });

      callAndExpect(
          "GET",
          "/cluster/" + TestContext.TEST_CLUSTER,
          Optional.<Map<String, String>>empty(),
          Optional.<String>empty(),
          Response.Status.NOT_FOUND);
    }
  }

  @Then("^reaper has a cluster called \"([^\"]*)\" in storage$")
  public void reaper_has_a_cluster_called_in_storage(String clusterName) throws Throwable {
    synchronized (BasicSteps.class) {
      callAndExpect("GET", "/cluster/" + clusterName,
          Optional.<Map<String, String>>empty(),
          Optional.<String>empty(), Response.Status.OK);
    }
  }

  @Then("^reaper has the last added cluster in storage$")
  public void reaper_has_the_last_added_cluster_in_storage() throws Throwable {
    synchronized (BasicSteps.class) {
      callAndExpect("GET", "/cluster/" + TestContext.TEST_CLUSTER,
          Optional.<Map<String, String>>empty(),
          Optional.<String>empty(), Response.Status.OK);
    }
  }

  @And("^reaper has no scheduled repairs for \"([^\"]*)\"$")
  public void reaper_has_no_scheduled_repairs_for(String clusterName) throws Throwable {
    synchronized (BasicSteps.class) {
      callAndExpect("GET", "/repair_schedule/cluster/" + clusterName,
          Optional.<Map<String, String>>empty(),
          Optional.of("[]"), Response.Status.OK);
    }
  }

  @When("^a new daily \"([^\"]*)\" repair schedule is added for \"([^\"]*)\" and keyspace \"([^\"]*)\"$")
  public void a_new_daily_repair_schedule_is_added_for(String repairType, String clusterName, String keyspace)
      throws Throwable {

    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(RAND.nextInt(RUNNERS.size()));
      Map<String, String> params = Maps.newHashMap();
      params.put("clusterName", clusterName);
      params.put("keyspace", keyspace);
      params.put("owner", TestContext.TEST_USER);
      params.put("intensity", "0.9");
      params.put("scheduleDaysBetween", "1");
      params.put("repairParallelism", repairType.equals("incremental") ? "parallel" : "sequential");
      params.put("incrementalRepair", repairType.equals("incremental") ? "True" : "False");
      Response response = runner.callReaper("POST", "/repair_schedule", Optional.of(params));
      int responseStatus = response.getStatus();
      String responseEntity = response.readEntity(String.class);

      Assertions.assertThat(
          ImmutableList.of(
            Response.Status.CREATED.getStatusCode(),
            Response.Status.NO_CONTENT.getStatusCode()))
          .withFailMessage(responseEntity)
          .contains(responseStatus);

      // rest command requests should not response with bodies, follow the location to GET that
      Assertions.assertThat(responseEntity).isEmpty();

      // follow to new location (to GET resource)
      response = runner.callReaper("GET", response.getLocation().toString(), Optional.empty());
      String responseData = response.readEntity(String.class);
      Assertions.assertThat(responseData).isNotBlank();
      RepairScheduleStatus schedule = SimpleReaperClient.parseRepairScheduleStatusJSON(responseData);

      if (Response.Status.CREATED.getStatusCode() == responseStatus) {
        testContext.addCurrentScheduleId(schedule.getId());
      } else {
        // if the original request to create the schedule failed then we have to wait til we can find it
        await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
          try {
            List<RepairScheduleStatus> schedules = runner.getClient().getRepairSchedulesForCluster(clusterName);
            Assertions.assertThat(schedules).withFailMessage(StringUtils.join(schedules, " , ")).hasSize(1);
            testContext.addCurrentScheduleId(schedules.get(0).getId());
          } catch (AssertionError ex) {
            LOG.warn(ex.getMessage());
            logResponse(runner, "/repair_schedule/cluster/" + TestContext.TEST_CLUSTER);
            return false;
          }
          return true;
        });
      }
    }
  }

  @When("^a new daily \"([^\"]*)\" repair schedule is added for the last added cluster and keyspace \"([^\"]*)\"$")
  public void a_new_daily_repair_schedule_is_added_for_the_last_added_cluster(String repairType, String keyspace)
      throws Throwable {

    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(RAND.nextInt(RUNNERS.size()));
      Map<String, String> params = Maps.newHashMap();
      params.put("clusterName", TestContext.TEST_CLUSTER);
      params.put("keyspace", keyspace);
      params.put("owner", TestContext.TEST_USER);
      params.put("intensity", "0.9");
      params.put("scheduleDaysBetween", "1");
      params.put("repairParallelism", repairType.equals("incremental") ? "parallel" : "sequential");
      params.put("incrementalRepair", repairType.equals("incremental") ? "True" : "False");
      Response response = runner.callReaper("POST", "/repair_schedule", Optional.of(params));
      int responseStatus = response.getStatus();
      String responseEntity = response.readEntity(String.class);

      Assertions
          .assertThat(
            ImmutableList.of(Response.Status.CREATED.getStatusCode(), Response.Status.NO_CONTENT.getStatusCode()))
          .withFailMessage(responseEntity)
          .contains(responseStatus);

      // rest command requests should not response with bodies, follow the location to GET that
      Assertions.assertThat(responseEntity).isEmpty();

      // follow to new location (to GET resource)
      response = runner.callReaper("GET", response.getLocation().toString(), Optional.empty());
      String responseData = response.readEntity(String.class);
      Assertions.assertThat(responseData).isNotBlank();
      RepairScheduleStatus schedule = SimpleReaperClient.parseRepairScheduleStatusJSON(responseData);

      if (Response.Status.CREATED.getStatusCode() == responseStatus) {
        testContext.addCurrentScheduleId(schedule.getId());
      } else {
        // if the original request to create the schedule failed then we have to wait til we can find it
        await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
          try {
            List<RepairScheduleStatus> schedules
                = runner.getClient().getRepairSchedulesForCluster(TestContext.TEST_CLUSTER);

            Assertions.assertThat(schedules).withFailMessage(StringUtils.join(schedules, " , ")).hasSize(1);
            testContext.addCurrentScheduleId(schedules.get(0).getId());
          } catch (AssertionError ex) {
            LOG.warn(ex.getMessage());
            logResponse(runner, "/repair_schedule/cluster/" + TestContext.TEST_CLUSTER);
            return false;
          }
          return true;
        });
      }
    }
  }

  @When("^a new daily repair schedule is added for the last added cluster "
      + "and keyspace \"([^\"]*)\" with next repair immediately$")
  public void a_new_daily_repair_schedule_is_added_for_the_last_added_cluster_and_keyspace_with_next_repair_immediately(
      String keyspace) throws Throwable {

    synchronized (BasicSteps.class) {
      LOG.info("adding a new daily repair schedule to keyspace: {}", keyspace);
      Map<String, String> params = Maps.newHashMap();
      params.put("clusterName", TestContext.TEST_CLUSTER);
      params.put("keyspace", keyspace);
      params.put("owner", TestContext.TEST_USER);
      params.put("intensity", "0.9");
      params.put("scheduleDaysBetween", "1");
      params.put("scheduleTriggerTime", DateTime.now().plusSeconds(1).toString());
      ReaperTestJettyRunner runner = RUNNERS.get(RAND.nextInt(RUNNERS.size()));
      Response response = runner.callReaper("POST", "/repair_schedule", Optional.of(params));
      int responseStatus = response.getStatus();
      String responseEntity = response.readEntity(String.class);

      Assertions.assertThat(
          ImmutableList.of(
            Response.Status.CREATED.getStatusCode(),
            Response.Status.CONFLICT.getStatusCode()))
          .withFailMessage(responseEntity)
          .contains(responseStatus);

      // non-error rest command requests should not response with bodies
      if (Response.Status.CONFLICT.getStatusCode() != responseStatus) {
        Assertions.assertThat(responseEntity).isEmpty();
      }

      // follow to new location (to GET resource)
      response = runner.callReaper("GET", response.getLocation().toString(), Optional.empty());
      String responseData = response.readEntity(String.class);
      Assertions.assertThat(responseData).isNotBlank();
      RepairScheduleStatus schedule = SimpleReaperClient.parseRepairScheduleStatusJSON(responseData);

      if (Response.Status.CREATED.getStatusCode() == responseStatus) {
        testContext.addCurrentScheduleId(schedule.getId());
      } else {
        // if the original request to create the schedule failed then we have to wait til we can find it
        await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
          try {
            List<RepairScheduleStatus> schedules
                = runner.getClient().getRepairSchedulesForCluster(TestContext.TEST_CLUSTER);

            Assertions.assertThat(schedules).withFailMessage(StringUtils.join(schedules, " , ")).hasSize(1);
            testContext.addCurrentScheduleId(schedules.get(0).getId());
          } catch (AssertionError ex) {
            LOG.warn(ex.getMessage());
            logResponse(runner, "/repair_schedule/cluster/" + TestContext.TEST_CLUSTER);
            return false;
          }
          return true;
        });
      }
    }
  }

  @And("^we wait for a scheduled repair run has started for cluster \"([^\"]*)\"$")
  public void a_scheduled_repair_run_has_started_for_cluster(String clusterName) throws Throwable {
    synchronized (BasicSteps.class) {
      final Set<UUID> runningRepairs = Sets.newConcurrentHashSet();
      RUNNERS.parallelStream().forEach(runner -> {
        LOG.info("waiting for a scheduled repair run to start for cluster: {}", clusterName);
        await().with().pollInterval(POLL_INTERVAL).atMost(2, MINUTES).until(() -> {

          Response resp = runner.callReaper("GET", "/repair_run/cluster/" + TestContext.TEST_CLUSTER, EMPTY_PARAMS);
          String responseData = resp.readEntity(String.class);

          if (Response.Status.OK.getStatusCode() != resp.getStatus() || StringUtils.isBlank(responseData)) {
            return false;
          }

          List<RepairRunStatus> runs = SimpleReaperClient.parseRepairRunStatusListJSON(responseData)
              .stream()
              .filter(r -> RepairRun.RunState.RUNNING == r.getState() || RepairRun.RunState.DONE == r.getState())
              .filter(r -> r.getCause().contains(testContext.getCurrentScheduleId().toString()))
              .collect(Collectors.toList());

          if (1 < runs.size()) {
            LOG.error("found duplicate repairs from same schedule and trigger time. deleting those behind…");
            logResponse(runner, "/repair_run/cluster/" + TestContext.TEST_CLUSTER);

            UUID toKeep = runs.stream()
                .sorted((r0, r1) -> (r0.getSegmentsRepaired() != r1.getSegmentsRepaired()
                      ? r1.getSegmentsRepaired() - r0.getSegmentsRepaired() : r0.getId().compareTo(r1.getId())))
                .findFirst()
                .get()
                .getId();

            Optional<Map<String,String>> deleteParams = Optional.of(ImmutableMap.of("owner", TestContext.TEST_USER));

            // pause the other repairs first
            runs.stream()
                .filter(r -> !r.getId().equals(toKeep))
                .forEachOrdered(r -> {
                  Response res = runner.callReaper("PUT", "/repair_run/" + r.getId() + "/state/PAUSED", EMPTY_PARAMS);
                  LOG.warn(res.readEntity(String.class));
                });

            // then delete the other repairs
            runs.stream()
                .filter(r -> !r.getId().equals(toKeep))
                .forEachOrdered(r -> {
                  Response res = runner.callReaper("DELETE", "/repair_run/" + r.getId(), deleteParams);
                  LOG.warn(res.readEntity(String.class));
                });

            return false;
          }

          if (runs.isEmpty()) {
            return false;
          }
          runningRepairs.add(runs.get(0).getId());
          return true;
        });
      });
      Assertions.assertThat(runningRepairs).hasSize(1);
      testContext.addCurrentRepairId(runningRepairs.iterator().next());
    }
  }

  @And("^reaper has scheduled repair for cluster called \"([^\"]*)\"$")
  public void reaper_has_scheduled_repair_for_cluster_called(String clusterName) throws Throwable {
    synchronized (BasicSteps.class) {
      callAndExpect(
          "GET",
          "/repair_schedule/cluster/" + clusterName,
          EMPTY_PARAMS,
          Optional.of("\"" + clusterName + "\""),
          Response.Status.OK);
    }
  }

  @And("^a second daily repair schedule is added for \"([^\"]*)\" and keyspace \"([^\"]*)\"$")
  public void a_second_daily_repair_schedule_is_added_for_and_keyspace(String clusterName, String keyspace)
      throws Throwable {

    synchronized (BasicSteps.class) {
      LOG.info("add second daily repair schedule: {}/{}", clusterName, keyspace);
      Map<String, String> params = Maps.newHashMap();
      params.put("clusterName", clusterName);
      params.put("keyspace", keyspace);
      params.put("owner", TestContext.TEST_USER);
      params.put("intensity", "0.8");
      params.put("scheduleDaysBetween", "1");
      ReaperTestJettyRunner runner = RUNNERS.get(RAND.nextInt(RUNNERS.size()));
      Response response = runner.callReaper("POST", "/repair_schedule", Optional.of(params));
      int responseStatus = response.getStatus();
      assertEquals(Response.Status.CREATED.getStatusCode(), responseStatus);
      // rest command requests should not response with bodies, follow the location to GET that
      Assertions.assertThat(response.readEntity(String.class)).isEmpty();
      // follow to new location (to GET resource)
      response = runner.callReaper("GET", response.getLocation().toString(), Optional.empty());
      String responseData = response.readEntity(String.class);
      Assertions.assertThat(responseData).isNotBlank();
      RepairScheduleStatus schedule = SimpleReaperClient.parseRepairScheduleStatusJSON(responseData);
      testContext.addCurrentScheduleId(schedule.getId());
    }
  }

  @And("^reaper has (\\d+) scheduled repairs for cluster called \"([^\"]*)\"$")
  public void reaper_has_scheduled_repairs_for_cluster_called(
      int expectedSchedules,
      String clusterName) throws Throwable {

    synchronized (BasicSteps.class) {
      CLIENTS.parallelStream().forEach(client -> {
        await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
          try {
            List<RepairScheduleStatus> schedules = client.getRepairSchedulesForCluster(clusterName);

            Assertions
                .assertThat(schedules)
                .withFailMessage(StringUtils.join(schedules, " , "))
                .hasSize(expectedSchedules);
          } catch (AssertionError ex) {
            LOG.warn(ex.getMessage());
            logResponse(RUNNERS.get(0), "/repair_schedule/cluster/" + TestContext.TEST_CLUSTER);
            return false;
          }
          return true;
        });
      });
    }
  }

  @And("^reaper has (\\d+) scheduled repairs for the last added cluster$")
  public void reaper_has_scheduled_repairs_for_the_last_added_cluster(int expectedSchedules) throws Throwable {
    synchronized (BasicSteps.class) {
      CLIENTS.parallelStream().forEach(client -> {
        await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
          try {
            List<RepairScheduleStatus> schedules = client.getRepairSchedulesForCluster(TestContext.TEST_CLUSTER);

            Assertions.assertThat(schedules)
                .withFailMessage(StringUtils.join(schedules, " , "))
                .hasSize(expectedSchedules);
          } catch (AssertionError ex) {
            LOG.warn(ex.getMessage());
            logResponse(RUNNERS.get(0), "/repair_schedule/cluster/" + TestContext.TEST_CLUSTER);
            return false;
          }
          return true;
        });
      });
    }
  }

  @When("^the last added schedule is deleted for cluster called \"([^\"]*)\"$")
  public void the_last_added_schedule_is_deleted_for_cluster_called(String clusterName) throws Throwable {
    synchronized (BasicSteps.class) {
      LOG.info("pause last added repair schedule with id: {}", testContext.getCurrentScheduleId());
      Map<String, String> params = Maps.newHashMap();
      params.put("state", "paused");

      callAndExpect(
          "PUT",
          "/repair_schedule/" + testContext.getCurrentScheduleId(),
          Optional.of(params),
          Optional.of("\"" + clusterName + "\""),
          Response.Status.OK,
          Response.Status.NO_CONTENT);

      LOG.info("delete last added repair schedule with id: {}", testContext.getCurrentScheduleId());
      params.clear();
      params.put("owner", TestContext.TEST_USER);

      callAndExpect("DELETE",
          "/repair_schedule/" + testContext.getCurrentScheduleId(),
          Optional.of(params),
          Optional.empty(),
          Response.Status.ACCEPTED,
          Response.Status.NOT_FOUND);

      await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
        try {
          callAndExpect(
              "DELETE",
              "/repair_schedule/" + testContext.getCurrentScheduleId(),
              Optional.of(params),
              Optional.empty(),
              Response.Status.NOT_FOUND);
        } catch (AssertionError ex) {
          LOG.warn("DELETE /repair_schedule/" + testContext.getCurrentScheduleId() + " failed: " + ex.getMessage());
          return false;
        }
        return true;
      });
    }
  }

  @When("^the last added schedule is deleted for the last added cluster$")
  public void the_last_added_schedule_is_deleted_for_the_last_added_cluster() throws Throwable {
    synchronized (BasicSteps.class) {
      LOG.info("pause last added repair schedule with id: {}", testContext.getCurrentScheduleId());
      Map<String, String> params = Maps.newHashMap();
      params.put("state", "paused");

      callAndExpect(
          "PUT",
          "/repair_schedule/" + testContext.getCurrentScheduleId(),
          Optional.of(params),
          Optional.of("\"" + TestContext.TEST_CLUSTER + "\""),
          Response.Status.OK,
          Response.Status.NO_CONTENT);

      LOG.info("delete last added repair schedule with id: {}", testContext.getCurrentScheduleId());
      params.clear();
      params.put("owner", TestContext.TEST_USER);

      callAndExpect(
          "DELETE",
          "/repair_schedule/" + testContext.getCurrentScheduleId(),
          Optional.of(params),
          Optional.empty(),
          Response.Status.ACCEPTED,
          Response.Status.NOT_FOUND);

      await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
        try {
          callAndExpect(
              "DELETE",
              "/repair_schedule/" + testContext.getCurrentScheduleId(),
              Optional.of(params),
              Optional.empty(),
              Response.Status.NOT_FOUND);
        } catch (AssertionError ex) {
          LOG.warn("DELETE /repair_schedule/" + testContext.getCurrentScheduleId() + " failed: " + ex.getMessage());
          return false;
        }
        return true;
      });
    }
  }

  @When("^all added schedules are deleted for the last added cluster$")
  public void all_added_schedules_are_deleted_for_the_last_added_cluster() throws Throwable {
    synchronized (BasicSteps.class) {
      final Set<RepairScheduleStatus> schedules = Sets.newConcurrentHashSet();

      RUNNERS.parallelStream().forEach(runner -> {
        Response response
            = runner.callReaper("GET", "/repair_schedule/cluster/" + TestContext.TEST_CLUSTER, EMPTY_PARAMS);

        assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
        String responseData = response.readEntity(String.class);
        Assertions.assertThat(responseData).isNotBlank();
        schedules.addAll(SimpleReaperClient.parseRepairScheduleStatusListJSON(responseData));
      });

      schedules.parallelStream().forEach((schedule) -> {
        LOG.info("pause last added repair schedule with id: {}", schedule.getId());
        Map<String, String> params = Maps.newHashMap();
        params.put("state", "paused");

        callAndExpect(
            "PUT",
            "/repair_schedule/" + schedule.getId(),
            Optional.of(params),
            Optional.empty(),
            Response.Status.OK,
            Response.Status.NO_CONTENT,
            Response.Status.NOT_FOUND);
      });

      schedules.stream().forEach((schedule) -> {
        LOG.info("delete last added repair schedule with id: {}", schedule.getId());
        Map<String, String> params = Maps.newHashMap();
        params.put("owner", TestContext.TEST_USER);

        callAndExpect(
            "DELETE",
            "/repair_schedule/" + schedule.getId(),
            Optional.of(params),
            Optional.empty(),
            Response.Status.ACCEPTED,
            Response.Status.NOT_FOUND);

        await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
          try {
            callAndExpect(
                "DELETE",
                "/repair_schedule/" + schedule.getId(),
                Optional.of(params),
                Optional.empty(),
                Response.Status.NOT_FOUND);
            return true;
          } catch (AssertionError ex) {
            LOG.warn("DELETE /repair_schedule/" + testContext.getCurrentScheduleId() + " failed: " + ex.getMessage());
            return false;
          }
        });
      });
    }
  }

  @And("^deleting cluster called \"([^\"]*)\" fails$")
  public void deleting_cluster_called_fails(String clusterName) throws Throwable {
    synchronized (BasicSteps.class) {
      await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
        try {
          callAndExpect(
              "DELETE",
              "/cluster/" + TestContext.TEST_CLUSTER,
              EMPTY_PARAMS,
              Optional.empty(),
              Response.Status.CONFLICT);
        } catch (AssertionError ex) {
          LOG.warn("DELETE /cluster/" + TestContext.TEST_CLUSTER + " failed: " + ex.getMessage());
          return false;
        }
        return true;
      });
    }
  }

  @And("^the last added cluster is (force |)deleted$")
  public void cluster_called_is_deleted(String force) throws Throwable {

    Optional<Map<String,String>> params = "force ".equals(force)
        ? Optional.of(Collections.singletonMap("force", "true"))
        : EMPTY_PARAMS;

    synchronized (BasicSteps.class) {
      callAndExpect(
          "DELETE",
          "/cluster/" + TestContext.TEST_CLUSTER,
          params,
          Optional.<String>empty(),
          Response.Status.ACCEPTED,
          Response.Status.NOT_FOUND);

      await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
        try {
          callAndExpect(
              "GET",
              "/cluster/" + TestContext.TEST_CLUSTER,
              EMPTY_PARAMS,
              Optional.<String>empty(),
              Response.Status.NOT_FOUND);
        } catch (AssertionError ex) {
          LOG.warn("GET /cluster/" + TestContext.TEST_CLUSTER + " failed: " + ex.getMessage());
          return false;
        }
        return true;
      });
    }
  }

  @Then("^reaper has no cluster called \"([^\"]*)\" in storage$")
  public void reaper_has_no_cluster_called_in_storage(String clusterName) throws Throwable {
    synchronized (BasicSteps.class) {
      callAndExpect(
          "GET",
          "/cluster/" + clusterName,
          Optional.<Map<String, String>>empty(),
          Optional.<String>empty(),
          Response.Status.NOT_FOUND);
    }
  }

  @Then("^reaper has no longer the last added cluster in storage$")
  public void reaper_has_no_longer_the_last_added_cluster_in_storage() throws Throwable {
    synchronized (BasicSteps.class) {
      callAndExpect(
          "GET",
          "/cluster/" + TestContext.TEST_CLUSTER,
          Optional.<Map<String, String>>empty(),
          Optional.<String>empty(),
          Response.Status.NOT_FOUND);
    }
  }

  @And("^a new repair(.*) is added for \"([^\"]*)\" and keyspace \"([^\"]*)\"$")
  public void a_new_repair_is_added_for_and_keyspace(String compaction, String clusterName, String keyspace)
      throws Throwable {

    synchronized (BasicSteps.class) {
      Map<String, String> params = Maps.newHashMap();
      params.put("clusterName", clusterName);
      params.put("keyspace", keyspace);
      params.put("owner", TestContext.TEST_USER);
      if ("with compaction".equals(compaction.trim())) {
        params.put("majorCompaction", "true");
      }

      ReaperTestJettyRunner runner = RUNNERS.get(RAND.nextInt(RUNNERS.size()));
      Response response = runner.callReaper("POST", "/repair_run", Optional.of(params));
      assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus());
      String responseData = response.readEntity(String.class);
      Assertions.assertThat(responseData).isNotBlank();
      RepairRunStatus run = SimpleReaperClient.parseRepairRunStatusJSON(responseData);
      testContext.addCurrentRepairId(run.getId());
    }
  }

  @When(
      "^a new repair is added for the last added cluster "
          + "and keyspace \"([^\"]*)\" with the table \"([^\"]*)\" blacklisted$")
  public void a_new_repair_is_added_for_and_keyspace_with_blacklisted_table(
      String keyspace, String blacklistedTable) throws Throwable {

    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(RAND.nextInt(RUNNERS.size()));
      Map<String, String> params = Maps.newHashMap();
      params.put("clusterName", TestContext.TEST_CLUSTER);
      params.put("keyspace", keyspace);
      params.put("owner", TestContext.TEST_USER);
      params.put("blacklistedTables", blacklistedTable);
      Response response = runner.callReaper("POST", "/repair_run", Optional.of(params));
      assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus());
      String responseData = response.readEntity(String.class);
      Assertions.assertThat(responseData).isNotBlank();
      RepairRunStatus run = SimpleReaperClient.parseRepairRunStatusJSON(responseData);
      testContext.addCurrentRepairId(run.getId());
    }
  }

  @When(
      "^a new repair is added for the last added cluster "
          + "and keyspace \"([^\"]*)\" for tables \"([^\"]*)\"$")
  public void a_new_repair_is_added_for_and_keyspace_for_tables(String keyspace, String tables) throws Throwable {
    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(RAND.nextInt(RUNNERS.size()));
      Map<String, String> params = Maps.newHashMap();
      params.put("clusterName", TestContext.TEST_CLUSTER);
      params.put("keyspace", keyspace);
      params.put("owner", TestContext.TEST_USER);
      params.put("tables", tables);
      Response response = runner.callReaper("POST", "/repair_run", Optional.of(params));
      assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus());
      String responseData = response.readEntity(String.class);
      Assertions.assertThat(responseData).isNotBlank();
      RepairRunStatus run = SimpleReaperClient.parseRepairRunStatusJSON(responseData);
      testContext.addCurrentRepairId(run.getId());
    }
  }

  @When("^a new repair is added for the last added cluster and keyspace \"([^\"]*)\"$")
  public void a_new_repair_is_added_for_the_last_added_cluster_and_keyspace(String keyspace) throws Throwable {
    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(RAND.nextInt(RUNNERS.size()));
      Map<String, String> params = Maps.newHashMap();
      params.put("clusterName", TestContext.TEST_CLUSTER);
      params.put("keyspace", keyspace);
      params.put("owner", TestContext.TEST_USER);
      Response response = runner.callReaper("POST", "/repair_run", Optional.of(params));
      assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus());
      String responseData = response.readEntity(String.class);
      Assertions.assertThat(responseData).isNotBlank();
      RepairRunStatus run = SimpleReaperClient.parseRepairRunStatusJSON(responseData);
      testContext.addCurrentRepairId(run.getId());
    }
  }

  @And("^the last added repair has table \"([^\"]*)\" in the blacklist$")
  public void the_last_added_repair_has_table_in_the_blacklist(String blacklistedTable) throws Throwable {
    synchronized (BasicSteps.class) {
      RUNNERS.parallelStream().forEach(runner -> {
        Response response = runner.callReaper("GET", "/repair_run/cluster/" + TestContext.TEST_CLUSTER, EMPTY_PARAMS);
        String responseData = response.readEntity(String.class);
        assertEquals(responseData, Response.Status.OK.getStatusCode(), response.getStatus());
        Assertions.assertThat(responseData).isNotBlank();
        List<RepairRunStatus> runs = SimpleReaperClient.parseRepairRunStatusListJSON(responseData);
        assertTrue(runs.get(0).getBlacklistedTables().contains(blacklistedTable));
      });
    }
  }

  @And("^the last added repair has twcs table \"([^\"]*)\" in the blacklist$")
  public void the_last_added_repair_has_twcs_table_in_the_blacklist(String twcsTable) throws Throwable {
    synchronized (BasicSteps.class) {
      final VersionNumber lowestNodeVersion = getCassandraVersion();

      RUNNERS.parallelStream().forEach(runner -> {
        Response response = runner.callReaper("GET", "/repair_run/cluster/" + TestContext.TEST_CLUSTER, EMPTY_PARAMS);
        String responseData = response.readEntity(String.class);
        assertEquals(responseData, Response.Status.OK.getStatusCode(), response.getStatus());
        Assertions.assertThat(responseData).isNotBlank();
        List<RepairRunStatus> runs = SimpleReaperClient.parseRepairRunStatusListJSON(responseData);
        if ((reaperVersion.isPresent()
            && 0 < VersionNumber.parse("1.4.0").compareTo(VersionNumber.parse(reaperVersion.get())))
            // while DTCS is available in 2.0.11 it is not visible over jmx until 2.1
            //  see `Table.DEFAULT_COMPACTION_STRATEGY`
            || VersionNumber.parse("2.1").compareTo(lowestNodeVersion) > 0) {

          Assertions
              .assertThat(runs.get(0).getColumnFamilies().contains(twcsTable))
              .isTrue();
        } else {
          // auto TWCS blacklisting was only added in Reaper 1.4.0, and requires Cassandra >= 2.1
          Assertions
              .assertThat(runs.get(0).getColumnFamilies().contains(twcsTable))
              .isFalse();
        }
      });
    }
  }

  @When("^a new incremental repair is added for \"([^\"]*)\" and keyspace \"([^\"]*)\"$")
  public void a_new_incremental_repair_is_added_for_and_keyspace(String clusterName, String keyspace) throws Throwable {
    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(RAND.nextInt(RUNNERS.size()));
      Map<String, String> params = Maps.newHashMap();
      params.put("clusterName", clusterName);
      params.put("keyspace", keyspace);
      params.put("owner", TestContext.TEST_USER);
      params.put("incrementalRepair", Boolean.TRUE.toString());
      Response response = runner.callReaper("POST", "/repair_run", Optional.of(params));
      String responseData = response.readEntity(String.class);
      assertEquals(responseData, Response.Status.CREATED.getStatusCode(), response.getStatus());
      Assertions.assertThat(responseData).isNotBlank();
      RepairRunStatus run = SimpleReaperClient.parseRepairRunStatusJSON(responseData);
      testContext.addCurrentRepairId(run.getId());
    }
  }

  @When("^a new incremental repair is added for the last added cluster and keyspace \"([^\"]*)\"$")
  public void a_new_incremental_repair_is_added_for_the_last_added_cluster_and_keyspace(String keyspace)
      throws Throwable {
    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(RAND.nextInt(RUNNERS.size()));
      Map<String, String> params = Maps.newHashMap();
      params.put("clusterName", TestContext.TEST_CLUSTER);
      params.put("keyspace", keyspace);
      params.put("owner", TestContext.TEST_USER);
      params.put("incrementalRepair", Boolean.TRUE.toString());
      Response response = runner.callReaper("POST", "/repair_run", Optional.of(params));
      String responseData = response.readEntity(String.class);
      assertEquals(responseData, Response.Status.CREATED.getStatusCode(), response.getStatus());
      Assertions.assertThat(responseData).isNotBlank();
      RepairRunStatus run = SimpleReaperClient.parseRepairRunStatusJSON(responseData);
      testContext.addCurrentRepairId(run.getId());
    }
  }

  @Then("^reaper has (\\d+) repairs for cluster called \"([^\"]*)\"$")
  public void reaper_has_repairs_for_cluster_called(int expected, String clusterName) throws Throwable {
    synchronized (BasicSteps.class) {
      RUNNERS.parallelStream().forEach(runner -> {
        Response response = runner.callReaper("GET", "/repair_run/cluster/" + clusterName, EMPTY_PARAMS);
        String responseData = response.readEntity(String.class);
        assertEquals(responseData, Response.Status.OK.getStatusCode(), response.getStatus());
        Assertions.assertThat(responseData).isNotBlank();
        List<RepairRunStatus> runs = SimpleReaperClient.parseRepairRunStatusListJSON(responseData);

        // a repair can be created multiple times by different reaper processes (duplicates are dealt with in time)
        assertTrue(
            String.format("Expected at least %s repairs. Found %s", expected, runs.size()),
            expected <= runs.size());
      });
    }
  }

  @Then("^reaper has (\\d+) started or done repairs for cluster called \"([^\"]*)\"$")
  public void reaper_has_running_repairs_for_cluster_called(int expected, String clusterName) throws Throwable {

    Set<RepairRun.RunState> startedStates = EnumSet.copyOf(
        Sets.newHashSet(RepairRun.RunState.RUNNING, RepairRun.RunState.DONE));

    synchronized (BasicSteps.class) {
      RUNNERS.parallelStream().forEach(runner -> {
        Response response = runner.callReaper("GET", "/repair_run/cluster/" + clusterName, EMPTY_PARAMS);
        String responseData = response.readEntity(String.class);
        assertEquals(responseData, Response.Status.OK.getStatusCode(), response.getStatus());
        Assertions.assertThat(responseData).isNotBlank();
        List<RepairRunStatus> runs = SimpleReaperClient.parseRepairRunStatusListJSON(responseData);
        long found = runs.stream().filter(rrs -> startedStates.contains(rrs.getState())).count();

        // a repair can be started multiple times by different reaper processes (duplicates are dealt with in time)
        assertTrue(
            String.format("Expected at least %s running or done repair runs. Found %s", expected, found),
            expected <= found);
      });
    }
  }

  @Then("^reaper has (\\d+) repairs for the last added cluster$")
  public void reaper_has_repairs_for_the_last_added_cluster(int expected) throws Throwable {
    synchronized (BasicSteps.class) {
      RUNNERS.parallelStream().forEach(runner -> {
        Response response = runner.callReaper("GET", "/repair_run/cluster/" + TestContext.TEST_CLUSTER, EMPTY_PARAMS);
        String responseData = response.readEntity(String.class);
        assertEquals(responseData, Response.Status.OK.getStatusCode(), response.getStatus());
        Assertions.assertThat(responseData).isNotBlank();
        List<RepairRunStatus> runs = SimpleReaperClient.parseRepairRunStatusListJSON(responseData);

        // a repair can be created multiple times by different reaper processes (duplicates are dealt with in time)
        assertTrue(
            String.format("Expected at least %s repairs. Found %s", expected, runs.size()),
            expected <= runs.size());
      });
    }
  }

  @Then("^reaper has (\\d+) started or done repairs for the last added cluster$")
  public void reaper_has_started_repairs_for_the_last_added_cluster(int expected) throws Throwable {

    Set<RepairRun.RunState> startedStates = EnumSet.copyOf(
        Sets.newHashSet(RepairRun.RunState.RUNNING, RepairRun.RunState.DONE));

    synchronized (BasicSteps.class) {
      RUNNERS.parallelStream().forEach(runner -> {
        Response response = runner.callReaper("GET", "/repair_run/cluster/" + TestContext.TEST_CLUSTER, EMPTY_PARAMS);
        String responseData = response.readEntity(String.class);
        assertEquals(responseData, Response.Status.OK.getStatusCode(), response.getStatus());
        Assertions.assertThat(responseData).isNotBlank();
        List<RepairRunStatus> runs = SimpleReaperClient.parseRepairRunStatusListJSON(responseData);
        long found = runs.stream().filter(rrs -> startedStates.contains(rrs.getState())).count();

        // a repair can be started multiple times by different reaper processes (duplicates are dealt with in time)
        assertTrue(
            String.format("Expected at least %s running or done repair runs. Found %s", expected, found),
            expected <= found);
      });
    }
  }

  @When("^all added repair runs are deleted for the last added cluster$")
  public void all_added_repair_runs_are_deleted_for_the_last_added_cluster() throws Throwable {
    synchronized (BasicSteps.class) {
      final Set<RepairRunStatus> runs = Sets.newConcurrentHashSet();

      RUNNERS.parallelStream().forEach(runner -> {
        Response response
            = runner.callReaper("GET", "/repair_run/cluster/" + TestContext.TEST_CLUSTER, EMPTY_PARAMS);

        assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
        String responseData = response.readEntity(String.class);
        Assertions.assertThat(responseData).isNotBlank();
        runs.addAll(SimpleReaperClient.parseRepairRunStatusListJSON(responseData));
      });

      runs.stream().forEach((run) -> {
        UUID id = run.getId();
        LOG.info("stopping repair run with id: {}", id);
        stopRepairRun(id);
      });

      Map<String, String> params = Maps.newHashMap();
      params.put("owner", TestContext.TEST_USER);

      runs.stream().forEach((run) -> {
        UUID id = run.getId();
        LOG.info("deleting repair run with id: {}", id);

        callAndExpect(
            "DELETE",
            "/repair_run/" + id,
            Optional.of(params),
            Optional.empty(),
            Response.Status.ACCEPTED,
            Response.Status.NOT_FOUND,
            Response.Status.CONFLICT);

        await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
          try {
            callAndExpect(
                "DELETE",
                "/repair_run/" + id,
                Optional.of(params),
                Optional.empty(),
                Response.Status.NOT_FOUND);
          } catch (AssertionError ex) {
            LOG.warn("DELETE /repair_run/" + testContext.getCurrentRepairId() + " failed: " + ex.getMessage());
            return false;
          }
          return true;
        });
      });
    }
  }

  @When("^a new daily \"([^\"]*)\" repair schedule is added "
      + "that already exists for \"([^\"]*)\" and keyspace \"([^\"]*)\"$")
  public void a_new_daily_repair_schedule_is_added_that_already_exists_for(
      String repairType,
      String clusterName,
      String keyspace) throws Throwable {

    synchronized (BasicSteps.class) {
      RUNNERS.parallelStream().forEach(runner -> {
        Map<String, String> params = Maps.newHashMap();
        params.put("clusterName", clusterName);
        params.put("keyspace", keyspace);
        params.put("owner", TestContext.TEST_USER);
        params.put("intensity", "0.9");
        params.put("scheduleDaysBetween", "1");
        params.put("repairParallelism", repairType.equals("incremental") ? "parallel" : "sequential");
        params.put("incrementalRepair", repairType.equals("incremental") ? "True" : "False");
        Response response = runner.callReaper("POST", "/repair_schedule", Optional.of(params));

        int status = response.getStatus();
        String responseEntity = response.readEntity(String.class);

        Assertions.assertThat(
              ImmutableList.of(Response.Status.NO_CONTENT.getStatusCode(), Response.Status.CONFLICT.getStatusCode()))
            .withFailMessage(responseEntity)
            .contains(status);
      });
    }
  }

  @And("^the last added repair is activated$")
  public void the_last_added_repair_is_activated_for() throws Throwable {
    synchronized (BasicSteps.class) {
      final AtomicBoolean set = new AtomicBoolean(false);
      RUNNERS.parallelStream().forEach(runner -> {

        Response response = runner.callReaper(
            "PUT",
            "/repair_run/" + testContext.getCurrentRepairId() + "/state/RUNNING",
            Optional.of(Maps.newHashMap()));

        int status = response.getStatus();
        String responseEntity = response.readEntity(String.class);

        Assertions.assertThat(
              ImmutableList.of(Response.Status.OK.getStatusCode(), Response.Status.NO_CONTENT.getStatusCode()))
            .withFailMessage(responseEntity)
            .contains(status);

        // rest command requests should not response with bodies, follow the location to GET that
        Assertions.assertThat(responseEntity).isEmpty();

        // follow to new location (to GET resource)
        response = runner.callReaper("GET", response.getLocation().toString(), Optional.empty());
        String responseData = response.readEntity(String.class);

        if (Response.Status.OK.getStatusCode() == status) {
          Assertions.assertThat(responseData).isNotBlank();
          RepairRunStatus run = SimpleReaperClient.parseRepairRunStatusJSON(responseData);
          testContext.addCurrentRepairId(run.getId());
          set.compareAndSet(false, true);
        }
      });
      Assertions.assertThat(set.get()).isTrue();

      callAndExpect(
          "PUT",
          "/repair_run/" + testContext.getCurrentRepairId() + "/state/RUNNING",
          Optional.empty(),
          Optional.empty(),
          Response.Status.NO_CONTENT);
    }
  }

  @When("^the last added repair is stopped$")
  public void the_last_added_repair_is_stopped_for() throws Throwable {
    synchronized (BasicSteps.class) {
      stopRepairRun(testContext.getCurrentRepairId());
    }
  }

  private void stopRepairRun(UUID repairRunId) {
    // given "state" is same as the current run state
    RUNNERS.parallelStream().forEach(runner -> {
      Response response = runner.callReaper("PUT", "/repair_run/" + repairRunId + "/state/PAUSED", EMPTY_PARAMS);
      int status = response.getStatus();
      String responseEntity = response.readEntity(String.class);

      Assertions.assertThat(
            ImmutableList.of(
                Response.Status.OK.getStatusCode(),
                Response.Status.NO_CONTENT.getStatusCode(),
                Response.Status.NOT_FOUND.getStatusCode(),
                Response.Status.CONFLICT.getStatusCode()))
          .withFailMessage(responseEntity)
          .contains(status);

      // non-error rest command requests should not response with bodies
      if (Response.Status.CONFLICT.getStatusCode() != status) {
        Assertions.assertThat(responseEntity).isEmpty();
      }
    });

    callAndExpect(
        "PUT",
        "/repair_run/" + repairRunId + "/state/PAUSED",
        EMPTY_PARAMS,
        Optional.empty(),
        Response.Status.NO_CONTENT,
        Response.Status.NOT_FOUND,
        Response.Status.CONFLICT);
  }

  @And("^we wait for at least (\\d+) segments to be repaired$")
  public void we_wait_for_at_least_segments_to_be_repaired(int nbSegmentsToBeRepaired) throws Throwable {
    synchronized (BasicSteps.class) {
      RUNNERS.parallelStream().forEach(runner -> {
        final AtomicReference<RepairRunStatus> run = new AtomicReference<>();
        try {
          await().with().pollInterval(POLL_INTERVAL.multiply(2)).atMost(5, MINUTES).until(() -> {
            try {
              Response response = runner
                  .callReaper("GET", "/repair_run/" + testContext.getCurrentRepairId(), EMPTY_PARAMS);

              String responseData = response.readEntity(String.class);

              Assertions
                  .assertThat(response.getStatus())
                  .withFailMessage(responseData)
                  .isEqualTo(Response.Status.OK.getStatusCode());

              Assertions.assertThat(responseData).isNotBlank();
              run.set(SimpleReaperClient.parseRepairRunStatusJSON(responseData));
              return nbSegmentsToBeRepaired <= run.get().getSegmentsRepaired();
            } catch (AssertionError ex) {
              LOG.error("GET /repair_run/" + testContext.getCurrentRepairId() + " failed: " + ex.getMessage());
              if (null != run.get()) {
                LOG.error("last event was: " + run.get().getLastEvent());
              }
              logResponse(runner, "/repair_run/cluster/" + TestContext.TEST_CLUSTER);
              return false;
            }
          });
        } catch (ConditionTimeoutException ex) {
          logResponse(runner, "/repair_run/cluster/" + TestContext.TEST_CLUSTER);
          throw ex;
        }
      });
    }
  }

  private static void logResponse(ReaperTestJettyRunner runner, String path) {
    Response resp = runner.callReaper("GET", path, EMPTY_PARAMS);
    if (Response.Status.OK.getStatusCode() == resp.getStatus()) {
      LOG.error("GET " + path + " returned:\n" + resp.readEntity(String.class));
    } else {
      LOG.error("GET " + path + TestContext.TEST_CLUSTER + " failed: " + resp.readEntity(String.class));
    }
  }

  @Then("^reseting one segment sets its state to not started$")
  public void reseting_one_segment_sets_its_state_to_not_started() throws Throwable {
    synchronized (BasicSteps.class) {
      RUNNERS.parallelStream().forEach(runner -> {
        await().with().pollInterval(1, SECONDS).atMost(2, MINUTES).until(
            () -> {
              Response response = runner.callReaper(
                      "GET",
                      "/repair_run/" + testContext.getCurrentRepairId() + "/segments",
                      EMPTY_PARAMS);

              String responseData = response.readEntity(String.class);
              if (Response.Status.OK.getStatusCode() == response.getStatus() && StringUtils.isNotBlank(responseData)) {
                List<RepairSegment> segments = SimpleReaperClient.parseRepairSegmentsJSON(responseData);

                boolean gotDoneSegments
                    = segments.stream().anyMatch(seg -> seg.getState() == RepairSegment.State.DONE);

                if (gotDoneSegments) {
                  TestContext.FINISHED_SEGMENT = segments
                          .stream()
                          .filter(seg -> seg.getState() == RepairSegment.State.DONE)
                          .map(segment -> segment.getId())
                          .findFirst()
                          .get();
                }
                return gotDoneSegments;
              }
              return false;
            });
      });

      RUNNERS.parallelStream().forEach(runner -> {
        await().with().pollInterval(POLL_INTERVAL).atMost(2, MINUTES).until(
            () -> {
              Response abort = runner.callReaper(
                      "POST",
                      "/repair_run/"
                          + testContext.getCurrentRepairId()
                          + "/segments/abort/"
                          + TestContext.FINISHED_SEGMENT,
                      EMPTY_PARAMS);

              Response response = runner.callReaper(
                      "GET",
                       "/repair_run/" + testContext.getCurrentRepairId() + "/segments",
                      EMPTY_PARAMS);

              String responseData = response.readEntity(String.class);
              if (Response.Status.OK.getStatusCode() == response.getStatus() && StringUtils.isNotBlank(responseData)) {
                List<RepairSegment> segments = SimpleReaperClient.parseRepairSegmentsJSON(responseData);

                return segments.stream().anyMatch(seg ->
                        seg.getId().equals(TestContext.FINISHED_SEGMENT)
                            && seg.getState() == RepairSegment.State.NOT_STARTED);
              }
              return false;
            });
      });
    }
  }

  @And("^the last added cluster has a keyspace called reaper_db$")
  public void the_last_added_cluster_has_a_keyspace_called_reaper_db() throws Throwable {
    synchronized (BasicSteps.class) {
      RUNNERS.parallelStream().forEach(runner -> {
        Response response = runner.callReaper("GET", "/cluster/" + TestContext.TEST_CLUSTER + "/tables", EMPTY_PARAMS);
        assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
        String responseData = response.readEntity(String.class);
        Assertions.assertThat(responseData).isNotBlank();
        Map<String, List<String>> tablesByKeyspace = SimpleReaperClient.parseTableListJSON(responseData);
        assertTrue(tablesByKeyspace.containsKey("reaper_db"));
        assertTrue(tablesByKeyspace.get("reaper_db").contains("repair_run"));
      });
    }
  }

  @When("^a cluster wide snapshot request is made to Reaper$")
  public void a_cluster_wide_snapshot_request_is_made_to_reaper() throws Throwable {
    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(0);
      Response response = runner.callReaper("POST", "/snapshot/cluster/" + TestContext.TEST_CLUSTER, EMPTY_PARAMS);
      assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
    }
  }

  @Then("^there is (\\d+) snapshot returned when listing snapshots$")
  public void there_is_1_snapshot_returned_when_listing_snapshots(int nbSnapshots) throws Throwable {
    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(0);

      Response response = runner.callReaper(
              "GET",
              "/snapshot/" + TestContext.TEST_CLUSTER + "/" + TestContext.SEED_HOST.split("@")[0],
              EMPTY_PARAMS);

      assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
      String responseData = response.readEntity(String.class);
      Assertions.assertThat(responseData).isNotBlank();
      Map<String, List<Snapshot>> snapshots = SimpleReaperClient.parseSnapshotMapJSON(responseData);
      assertEquals(nbSnapshots, snapshots.keySet().size());
    }
  }

  @When("^a request is made to clear the existing snapshot cluster wide$")
  public void a_request_is_made_to_clear_the_existing_snapshots_cluster_wide() throws Throwable {
    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(0);

      Response response = runner.callReaper(
              "GET",
              "/snapshot/" + TestContext.TEST_CLUSTER + "/" + TestContext.SEED_HOST.split("@")[0],
              EMPTY_PARAMS);

      assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
      String responseData = response.readEntity(String.class);
      Assertions.assertThat(responseData).isNotBlank();
      Map<String, List<Snapshot>> snapshots = SimpleReaperClient.parseSnapshotMapJSON(responseData);

      snapshots.keySet().stream().forEach(snapshot -> {
        callAndExpect(
            "DELETE",
            "/snapshot/cluster/" + TestContext.TEST_CLUSTER + "/" + snapshot,
            Optional.empty(),
            Optional.empty(),
            Response.Status.ACCEPTED,
            Response.Status.NOT_FOUND);
      });
    }
  }

  @When("^a snapshot request for the seed host is made to Reaper$")
  public void a_host_snapshot_request_is_made_to_reaper() throws Throwable {
    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(0);

      Response response = runner.callReaper(
              "POST",
              "/snapshot/" + TestContext.TEST_CLUSTER + "/" + TestContext.SEED_HOST.split("@")[0],
              EMPTY_PARAMS);

      assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
    }
  }

  @When("^a request is made to clear the seed host existing snapshots$")
  public void a_request_is_made_to_clear_the_existing_host_snapshots() throws Throwable {
    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(0);

      Response response = runner.callReaper(
              "GET",
              "/snapshot/" + TestContext.TEST_CLUSTER + "/" + TestContext.SEED_HOST.split("@")[0],
              EMPTY_PARAMS);

      assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
      String responseData = response.readEntity(String.class);
      Assertions.assertThat(responseData).isNotBlank();
      Map<String, List<Snapshot>> snapshots = SimpleReaperClient.parseSnapshotMapJSON(responseData);

      snapshots.keySet().stream().forEach(snapshot -> {
        callAndExpect(
            "DELETE",
            "/snapshot/" + TestContext.TEST_CLUSTER
                + "/" + TestContext.SEED_HOST.split("@")[0] + "/" + snapshot,
            Optional.empty(),
            Optional.empty(),
            Response.Status.ACCEPTED,
            Response.Status.NOT_FOUND);
      });
    }
  }

  @When("^a (GET|POST|PUT|DELETE) ([^\"]*) is made$")
  public void aRequestIsMade(String method, String requestPath) throws Throwable {
    RUNNERS.parallelStream().forEach(runner -> lastResponse = runner.callReaper(method, requestPath, EMPTY_PARAMS));
  }

  @Then("^a \"([^\"]*)\" response is returned$")
  public void aResponseIsReturned(String statusDescription) throws Throwable {
    Assertions.assertThat(lastResponse.getStatus()).isEqualTo(httpStatus(statusDescription));
  }

  @Then("^the response was redirected to the login page$")
  public void theResponseWasRedirectedToTheLoginPage() throws Throwable {
    assertTrue(lastResponse.hasEntity());
    assertTrue(lastResponse.readEntity(String.class).contains("<title>Not a real login page</title>"));
  }

  @And("^we can collect the tpstats from a seed node$")
  public void we_can_collect_the_tpstats_from_the_seed_node() throws Throwable {
    synchronized (BasicSteps.class) {
      // XXX – this assertion does not work in upgrade tests. unknown reason.
      if (!reaperVersion.isPresent()) {
        // any collected tpstats via any reaper satifies this test
        AtomicBoolean collected = new AtomicBoolean(false);
        List<String> seeds = ImmutableList.copyOf(TestContext.TEST_CLUSTER_SEED_HOSTS.keySet());

        RUNNERS.parallelStream().forEach(runner -> {
          await().with().pollInterval(POLL_INTERVAL).atMost(2, MINUTES).until(() -> {
            String seed = seeds.get(RAND.nextInt(seeds.size()));

            Response response = runner.callReaper(
                    "GET",
                    "/node/tpstats/" + TestContext.TEST_CLUSTER + "/" + seed,
                    EMPTY_PARAMS);

            String responseData = response.readEntity(String.class);
            if (Response.Status.OK.getStatusCode() == response.getStatus() && StringUtils.isNotBlank(responseData)) {
              List<ThreadPoolStat> tpstats = SimpleReaperClient.parseTpStatJSON(responseData);
              if (tpstats.isEmpty()) {
                LOG.error("Got empty response from {}", seed);
              }
              long readStageTotal = tpstats.stream().filter(tpstat -> tpstat.getName().equals("ReadStage")).count();

              long readStageCompleted = tpstats.stream()
                  .filter(tpstat -> tpstat.getName().equals("ReadStage"))
                  .filter(tpstat -> tpstat.getCurrentlyBlockedTasks() == 0)
                  .filter(tpstat -> tpstat.getCompletedTasks() > 0)
                  .count();

              collected.compareAndSet(false, 1 == readStageTotal && 1 == readStageCompleted);
            }
            return collected.get();
          });
        });
      }
    }
  }

  @And("^we can collect the dropped messages stats from a seed node$")
  public void we_can_collect_the_dropped_messages_stats_from_the_seed_node() throws Throwable {
    synchronized (BasicSteps.class) {
      // XXX – this assertion does not work in upgrade tests. unknown reason.
      if (!reaperVersion.isPresent()) {
        // any collected dropped messages via any reaper satifies this test
        AtomicBoolean collected = new AtomicBoolean(false);
        List<String> seeds = ImmutableList.copyOf(TestContext.TEST_CLUSTER_SEED_HOSTS.keySet());

        RUNNERS.parallelStream().forEach(runner -> {
          await().with().pollInterval(POLL_INTERVAL).atMost(2, MINUTES).until(() -> {
            String seed = seeds.get(RAND.nextInt(seeds.size()));

            Response response = runner.callReaper(
                    "GET",
                    "/node/dropped/" + TestContext.TEST_CLUSTER + "/" + seed,
                    EMPTY_PARAMS);

            String responseData = response.readEntity(String.class);
            if (Response.Status.OK.getStatusCode() == response.getStatus() && StringUtils.isNotBlank(responseData)) {
              List<DroppedMessages> dropped = SimpleReaperClient.parseDroppedMessagesJSON(responseData);
              if (dropped.isEmpty()) {
                LOG.error("Got empty response from {}", seed);
              }

              long readDroppedTotal = dropped.stream()
                  .filter(drop -> "READ".equals(drop.getName()) || "READ_REQ".equals(drop.getName()))
                  .count();

              long readDroppedCount = dropped.stream()
                  .filter(drop -> "READ".equals(drop.getName()) || "READ_REQ".equals(drop.getName()) )
                  .filter(drop -> drop.getCount() >= 0)
                  .count();

              collected.compareAndSet(false, 1 == readDroppedTotal && 1 == readDroppedCount);
            }
            return collected.get();
          });
        });
      }
    }
  }

  @And("^we can collect the client request metrics from a seed node$")
  public void we_can_collect_the_client_request_metrics_from_the_seed_node() throws Throwable {
    synchronized (BasicSteps.class) {
      // XXX – this assertion does not work in upgrade tests. unknown reason.
      if (!reaperVersion.isPresent()) {
        final AtomicBoolean collected = new AtomicBoolean(false);
        List<String> seeds = ImmutableList.copyOf(TestContext.TEST_CLUSTER_SEED_HOSTS.keySet());

        RUNNERS.parallelStream().forEach(runner -> {
          await().with().pollInterval(POLL_INTERVAL).atMost(2, MINUTES).until(() -> {
            String seed = seeds.get(RAND.nextInt(seeds.size()));

            Response response = runner.callReaper(
                "GET",
                "/node/clientRequestLatencies/" + TestContext.TEST_CLUSTER + "/" + seed,
                EMPTY_PARAMS);

            String responseData = response.readEntity(String.class);
            if (Response.Status.OK.getStatusCode() == response.getStatus() && StringUtils.isNotBlank(responseData)) {
              List<MetricsHistogram> metrics = SimpleReaperClient.parseClientRequestMetricsJSON(responseData);
              if (metrics.isEmpty()) {
                LOG.error("Got empty response from {}", seed);
              }
              long writeMetrics = metrics.stream().filter(metric -> metric.getName().startsWith("Write")).count();
              collected.compareAndSet(false, 1 <= writeMetrics);
            }
            return collected.get();
          });
        });
      }
    }
  }

  @And("^the seed node has vnodes$")
  public void the_seed_node_has_vnodes() {
    synchronized (BasicSteps.class) {
      RUNNERS.parallelStream()
          .forEach(
              runner -> {
                Response response = runner.callReaper(
                        "GET",
                        "/node/tokens/" + TestContext.TEST_CLUSTER + "/" + TestContext.SEED_HOST.split("@")[0],
                        EMPTY_PARAMS);

                assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
                String responseData = response.readEntity(String.class);
                Assertions.assertThat(responseData).isNotBlank();
                List<String> tokens = SimpleReaperClient.parseTokenListJSON(responseData);
                assertTrue(tokens.size() >= 1);
              });
    }
  }

  private static int httpStatus(String statusCodeDescriptions) {
    String enumName = statusCodeDescriptions.toUpperCase().replace(' ', '_');
    return Response.Status.valueOf(enumName).getStatusCode();
  }

  private static void createKeyspace(String keyspaceName) {
    try (Cluster cluster = buildCluster(); Session tmpSession = cluster.connect()) {
      VersionNumber lowestNodeVersion = getCassandraVersion(tmpSession);

      try {
        if (null == tmpSession.getCluster().getMetadata().getKeyspace(keyspaceName)) {
          tmpSession.execute(
              "CREATE KEYSPACE "
                  + (VersionNumber.parse("2.0").compareTo(lowestNodeVersion) <= 0 ? "IF NOT EXISTS " : "")
                  + keyspaceName
                + " WITH replication = {" + buildNetworkTopologyStrategyString(cluster) + "}");
        }
      } catch (AlreadyExistsException ignore) { }
    }
  }

  static String buildNetworkTopologyStrategyString(Cluster cluster) {
    Map<String, Integer> ntsMap = Maps.newHashMap();
    for (Host host : cluster.getMetadata().getAllHosts()) {
      String dc = host.getDatacenter();
      ntsMap.put(dc, 1 + ntsMap.getOrDefault(dc, 0));
    }
    StringBuilder builder = new StringBuilder("'class':'NetworkTopologyStrategy',");
    for (Map.Entry<String, Integer> e : ntsMap.entrySet()) {
      builder.append("'").append(e.getKey()).append("':").append(e.getValue()).append(",");
    }
    return builder.substring(0, builder.length() - 1);
  }

  private static Cluster buildCluster() {
    return Cluster.builder()
        .addContactPoint("127.0.0.1")
        .withSocketOptions(new SocketOptions().setConnectTimeoutMillis(20000).setReadTimeoutMillis(40000))
        .withoutJMXReporting()
        .build();
  }

  private static VersionNumber getCassandraVersion() {
    try (Cluster cluster = buildCluster(); Session tmpSession = cluster.connect()) {
      return getCassandraVersion(tmpSession);
    }
  }

  private static VersionNumber getCassandraVersion(Session tmpSession) {

    return tmpSession
            .getCluster()
            .getMetadata()
            .getAllHosts()
            .stream()
            .map(host -> host.getCassandraVersion())
            .min(VersionNumber::compareTo)
            .get();
  }

  private static void createTable(String keyspaceName, String tableName) {
    try (Cluster cluster = buildCluster(); Session tmpSession = cluster.connect()) {
      VersionNumber lowestNodeVersion = getCassandraVersion(tmpSession);

      String createTableStmt
          = "CREATE TABLE "
              + (VersionNumber.parse("2.0").compareTo(lowestNodeVersion) <= 0 ? "IF NOT EXISTS " : "")
              + keyspaceName
              + "."
              + tableName
              + "(id int PRIMARY KEY, value text)";

      if (tableName.endsWith("twcs")) {
        if (((VersionNumber.parse("3.0.8").compareTo(lowestNodeVersion) <= 0
            && VersionNumber.parse("3.0.99").compareTo(lowestNodeVersion) >= 0)
            || VersionNumber.parse("3.8").compareTo(lowestNodeVersion) <= 0)) {
          // TWCS is available by default
          createTableStmt
              += " WITH compaction = {'class':'TimeWindowCompactionStrategy',"
                  + "'compaction_window_size': '1', "
                  + "'compaction_window_unit': 'MINUTES'}";
        } else if (VersionNumber.parse("2.0.11").compareTo(lowestNodeVersion) <= 0) {
          createTableStmt += " WITH compaction = {'class':'DateTieredCompactionStrategy'}";
        }
      }

      try {
        if (null == tmpSession.getCluster().getMetadata().getKeyspace(keyspaceName).getTable(tableName)) {
          tmpSession.execute(createTableStmt);
        }
      } catch (AlreadyExistsException ignore) { }

      for (int i = 0; i < 100; i++) {
        tmpSession.execute(
            "INSERT INTO " + keyspaceName + "." + tableName + "(id, value) VALUES(" + i + ",'" + i + "')");
      }
    }
  }

  @When("^a get all subscriptions request is made$")
  public void aGetAllSubscriptionsRequestIsMade() throws Throwable {
    synchronized (BasicSteps.class) {
      testContext.updateRetrievedEventSubscriptions(
          callSubscription("GET", Optional.empty(), Optional.empty(), Response.Status.OK));
    }
  }

  @When("^a get-subscriptions request is made for cluster \"([^\"]*)\"$")
  public void aGetSubscriptionsRequestIsMadeForCluster(String clusterName) throws Throwable {
    synchronized (BasicSteps.class) {
      HashMap<String, String> params = new HashMap<>();
      params.put("clusterName", clusterName);

      testContext.updateRetrievedEventSubscriptions(
          callSubscription("GET", Optional.of(params), Optional.empty(), Response.Status.OK));
    }
  }

  @When("^a get-subscription request is made for the last inserted ID$")
  public void aGetSubscriptionRequestIsMadeForTheLastInsertedID() throws Throwable {
    synchronized (BasicSteps.class) {
      DiagEventSubscription last = testContext.getCurrentEventSubscription();

      testContext.updateRetrievedEventSubscriptions(
          callSubscription(
              "GET",
              Optional.empty(),
              Optional.ofNullable(last.getId().orElse(null)),
              Response.Status.OK));
    }
  }

  @When("^the last created subscription is deleted$")
  public void theLastCreatedSubscriptionIsDeleted() throws Throwable {
    synchronized (BasicSteps.class) {
      DiagEventSubscription last = testContext.removeCurrentEventSubscription();

      callAndExpect(
          "DELETE",
          "/diag_event/subscription/" + last.getId().get(),
          Optional.empty(),
          Optional.empty(),
          Response.Status.ACCEPTED,
          Response.Status.NOT_FOUND);

      await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
        try {
          callAndExpect(
              "DELETE",
              "/diag_event/subscription/" + last.getId().get(),
              Optional.empty(),
              Optional.empty(),
              Response.Status.NOT_FOUND);
        } catch (AssertionError ex) {
          LOG.warn("DELETE /diag_event/subscription/" + last.getId().get() + " failed: " + ex.getMessage());
          return false;
        }
        return true;
      });
    }
  }

  @When("^all created subscriptions are deleted$")
  public void allCreatedSubscriptionsAreDeleted() throws Throwable {
    synchronized (BasicSteps.class) {
      aGetAllSubscriptionsRequestIsMade();
      testContext.getRetrievedEventSubscriptions()
          .parallelStream()
          .forEach((sub) -> {
            callAndExpect(
                "DELETE",
                "/diag_event/subscription/" + sub.getId().get(),
                Optional.empty(),
                Optional.empty(),
                Response.Status.ACCEPTED,
                Response.Status.NOT_FOUND);
            try {
              await().with().pollInterval(POLL_INTERVAL).atMost(1, MINUTES).until(() -> {
                try {
                  callAndExpect(
                      "DELETE",
                      "/diag_event/subscription/" + sub.getId().get(),
                      Optional.empty(),
                      Optional.empty(),
                      Response.Status.NOT_FOUND);
                } catch (AssertionError ex) {
                  LOG.warn("DELETE /diag_event/subscription/" + sub.getId().get() + " failed: " + ex.getMessage());
                  return false;
                }
                return true;
              });
            } catch (ConditionTimeoutException ex) {
              logResponse(RUNNERS.get(RAND.nextInt(RUNNERS.size())) , "/diag_event/subscription");
              throw ex;
            }
          });
    }
  }

  @And("^the following subscriptions are created:$")
  public void theFollowingSubscriptionsExist(List<Map<String, String>> subscriptions) throws Throwable {
    synchronized (BasicSteps.class) {
      ReaperTestJettyRunner runner = RUNNERS.get(RAND.nextInt(RUNNERS.size()));
      for (Map<String, String> sub : subscriptions) {
        // see org.apache.cassandra.diag.DiagnosticEventPersistence.java#L137
        sub = Maps.newHashMap(sub);
        for (Map.Entry<String,String> eventType : EVENT_TYPES.entrySet()) {
          sub.put("events", sub.get("events").replace(eventType.getKey(), eventType.getValue()));
        }
        Response response = runner.callReaper("POST", "/diag_event/subscription", Optional.of(sub));

        Assertions
            .assertThat(response.getStatus())
            .isIn(Response.Status.CREATED.getStatusCode(), Response.Status.NO_CONTENT.getStatusCode());

        // rest command requests should not response with bodies, follow the location to GET that
        Assertions.assertThat(response.hasEntity()).isFalse();
        // follow to new location (to GET resource)
        response = runner.callReaper("GET", response.getLocation().toString(), Optional.empty());
        Assertions.assertThat(response.getStatus()).isEqualTo(Response.Status.OK.getStatusCode());
        Assertions.assertThat(response.hasEntity()).isTrue();
        String responseData = response.readEntity(String.class);
        DiagEventSubscription created = SimpleReaperClient.parseEventSubscriptionJSON(responseData);
        DiagEventSubscription provided = DiagEventSubscriptionMapper.fromParamMap(sub);
        Assertions.assertThat(provided.withId(created.getId().get())).isEqualTo(created);
        testContext.addCurrentEventSubscription(created);
      }
    }
  }

  @Then("^the returned list of subscriptions is empty$")
  public void reaperReturnsAnEmptyListOfSubscriptions() {
    synchronized (BasicSteps.class) {
      Assertions.assertThat(testContext.getRetrievedEventSubscriptions()).isEmpty();
    }
  }

  @Then("^the returned list of subscriptions is:$")
  public void theReturnedListOfSubscriptionsIs(List<Map<String, String>> subscriptions) throws Throwable {
    synchronized (BasicSteps.class) {

      List<DiagEventSubscription> expected = subscriptions
          .stream()
          .map((sub) -> {
            // see org.apache.cassandra.diag.DiagnosticEventPersistence.java#L137
            EVENT_TYPES.forEach((name, fqn) -> sub.get("events").replace(name, fqn));
            return sub;
          })
          .map(DiagEventSubscriptionMapper::fromParamMap)
          .collect(Collectors.toList());

      Assertions.assertThat(testContext.getRetrievedEventSubscriptions().size()).isEqualTo(expected.size());

      List<DiagEventSubscription> lastRetrieved = testContext.getRetrievedEventSubscriptions()
          .stream()
          .map(s ->
              new DiagEventSubscription(
                  Optional.empty(),
                  s.getCluster(),
                  Optional.of(s.getDescription()),
                  s.getNodes(),
                  s.getEvents(),
                  s.getExportSse(),
                  s.getExportFileLogger(),
                  s.getExportHttpEndpoint()))
          .collect(Collectors.toList());

      for (DiagEventSubscription sub : expected) {
        Assertions.assertThat(lastRetrieved).contains(sub);
      }
    }
  }

  private List<DiagEventSubscription> callSubscription(
      String method,
      Optional<Map<String, String>> params,
      Optional<UUID> id,
      Response.Status... expectedStatuses) {

    ReaperTestJettyRunner runner = RUNNERS.get(RAND.nextInt(RUNNERS.size()));
    String path = "/diag_event/subscription" + (id.isPresent() ? "/" + id.get() : "");
    Response response = runner.callReaper(method, path, params);

    Assertions
            .assertThat(Arrays.stream(expectedStatuses).map(Response.Status::getStatusCode))
            .contains(response.getStatus());

    String responseData = response.readEntity(String.class);

    return id.isPresent()
        ? ImmutableList.of(SimpleReaperClient.parseEventSubscriptionJSON(responseData))
        : SimpleReaperClient.parseEventSubscriptionsListJSON(responseData);
  }

  private static boolean isInstanceOfDistributedStorage(String storageClassname) {
    String csCls = CassandraStorage.class.getName();
    String pgCls = PostgresStorage.class.getName();
    return csCls.equals(storageClassname) || pgCls.equals(storageClassname);
  }
}