/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.datastax.driver.core.policies;

import static org.assertj.core.api.Assertions.assertThat;
import static org.scassandra.http.client.Consistency.SERIAL;
import static org.scassandra.http.client.PrimingRequest.then;
import static org.scassandra.http.client.Result.closed_connection;
import static org.scassandra.http.client.Result.read_failure;
import static org.scassandra.http.client.Result.read_request_timeout;
import static org.scassandra.http.client.Result.unavailable;
import static org.scassandra.http.client.Result.write_failure;
import static org.scassandra.http.client.Result.write_request_timeout;
import static org.scassandra.http.client.WriteTypePrime.UNLOGGED_BATCH;
import static org.testng.Assert.fail;

import com.datastax.driver.core.ConsistencyLevel;
import com.datastax.driver.core.SocketOptions;
import com.datastax.driver.core.WriteType;
import com.datastax.driver.core.exceptions.DriverException;
import com.datastax.driver.core.exceptions.NoHostAvailableException;
import com.datastax.driver.core.exceptions.OperationTimedOutException;
import com.datastax.driver.core.exceptions.ReadFailureException;
import com.datastax.driver.core.exceptions.ReadTimeoutException;
import com.datastax.driver.core.exceptions.TransportException;
import com.datastax.driver.core.exceptions.UnavailableException;
import com.datastax.driver.core.exceptions.WriteFailureException;
import com.datastax.driver.core.exceptions.WriteTimeoutException;
import org.assertj.core.api.Assertions;
import org.assertj.core.api.Fail;
import org.scassandra.http.client.ClosedConnectionConfig;
import org.scassandra.http.client.PrimingRequest;
import org.scassandra.http.client.ReadTimeoutConfig;
import org.scassandra.http.client.Result;
import org.scassandra.http.client.UnavailableConfig;
import org.scassandra.http.client.WriteTimeoutConfig;
import org.scassandra.http.client.WriteTypePrime;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

@SuppressWarnings("deprecation")
public class DowngradingConsistencyRetryPolicyIntegrationTest
    extends AbstractRetryPolicyIntegrationTest {

  public DowngradingConsistencyRetryPolicyIntegrationTest() {
    super(DowngradingConsistencyRetryPolicy.INSTANCE);
  }

  /**
   * @return An array of pairs that match # of alive replicas with the expected downgraded CL used
   *     on read/write/unavailable.
   */
  @DataProvider
  public static Object[][] consistencyLevels() {
    return new Object[][] {
      {4, ConsistencyLevel.THREE},
      {3, ConsistencyLevel.THREE},
      {2, ConsistencyLevel.TWO},
      {1, ConsistencyLevel.ONE}
    };
  }

  /** @return Write Types for which we expect a rethrow if used and there are no received acks. */
  @DataProvider
  public static Object[][] rethrowWriteTypes() {
    return new Object[][] {
      {WriteTypePrime.SIMPLE},
      {WriteTypePrime.BATCH},
      {WriteTypePrime.COUNTER},
      {WriteTypePrime.CAS}
    };
  }

  /** @return Write Types for which we expect an ignore if used and there are received acks. */
  @DataProvider
  public static Object[][] ignoreWriteTypesWithReceivedAcks() {
    return new Object[][] {{WriteTypePrime.SIMPLE}, {WriteTypePrime.BATCH}};
  }

  /**
   * Ensures that when handling a read timeout with {@link DowngradingConsistencyRetryPolicy} that a
   * retry is reattempted with {@link ConsistencyLevel#ONE} if the consistency level on the
   * statement executed is {@link ConsistencyLevel#EACH_QUORUM}, even if the number of known alive
   * replicas was 0.
   *
   * @jira_ticket JAVA-1005
   * @test_category retry_policy
   */
  @Test(groups = "short")
  public void should_retry_once_on_same_host_from_each_quorum_to_one() {
    simulateError(1, read_request_timeout, new ReadTimeoutConfig(0, 3, false));

    try {
      queryWithCL(ConsistencyLevel.EACH_QUORUM);
    } catch (ReadTimeoutException e) {
      assertThat(e.getConsistencyLevel()).isEqualTo(ConsistencyLevel.ONE);
    }

    assertOnReadTimeoutWasCalled(2);
    assertThat(errors.getRetries().getCount()).isEqualTo(1);
    assertThat(errors.getReadTimeouts().getCount()).isEqualTo(2);
    assertThat(errors.getRetriesOnReadTimeout().getCount()).isEqualTo(1);
    assertQueried(1, 2);
    assertQueried(2, 0);
    assertQueried(3, 0);
  }

  /**
   * Ensures that when handling a read timeout with {@link DowngradingConsistencyRetryPolicy} that a
   * retry is reattempted with a {@link ConsistencyLevel} that matches min(received
   * acknowledgements, THREE) and is only retried once.
   *
   * @param received The number of received acknowledgements to use in read timeout.
   * @param expectedDowngradedCL The consistency level that is expected to be used on the retry.
   * @test_category retry_policy
   */
  @Test(groups = "short", dataProvider = "consistencyLevels")
  public void should_retry_once_on_same_host_with_reduced_consistency_level_on_read_timeout(
      int received, ConsistencyLevel expectedDowngradedCL) {
    simulateError(1, read_request_timeout, new ReadTimeoutConfig(received, received + 1, true));

    try {
      query();
      fail("expected an ReadTimeoutException");
    } catch (ReadTimeoutException e) {
      assertThat(e.getConsistencyLevel()).isEqualTo(expectedDowngradedCL);
    }

    assertOnReadTimeoutWasCalled(2);
    assertThat(errors.getRetries().getCount()).isEqualTo(1);
    assertThat(errors.getReadTimeouts().getCount()).isEqualTo(2);
    assertThat(errors.getRetriesOnReadTimeout().getCount()).isEqualTo(1);
    assertQueried(1, 2);
    assertQueried(2, 0);
    assertQueried(3, 0);
  }

  /**
   * Ensures that when handling a read timeout with {@link DowngradingConsistencyRetryPolicy} that a
   * retry is reattempted if data was not retrieved, but enough replicas were alive to handle the
   * request.
   *
   * @test_category retry_policy
   */
  @Test(groups = "short")
  public void should_retry_once_if_not_data_was_retrieved_and_enough_replicas_alive() {
    simulateError(1, read_request_timeout, new ReadTimeoutConfig(1, 1, false));

    try {
      query();
      fail("expected an ReadTimeoutException");
    } catch (ReadTimeoutException e) {
      /*expected*/
    }

    assertOnReadTimeoutWasCalled(2);
    assertThat(errors.getRetries().getCount()).isEqualTo(1);
    assertThat(errors.getReadTimeouts().getCount()).isEqualTo(2);
    assertThat(errors.getRetriesOnReadTimeout().getCount()).isEqualTo(1);
    assertQueried(1, 2);
    assertQueried(2, 0);
    assertQueried(3, 0);
  }

  /**
   * Ensures that when handling a read timeout with {@link DowngradingConsistencyRetryPolicy} that a
   * retry is not attempted if no replicas were alive. In a real scenario, this would not be
   * expected as we'd anticipate an {@link UnavailableException} instead.
   *
   * @test_category retry_policy
   */
  @Test(groups = "short")
  public void should_rethrow_if_no_hosts_alive_on_read_timeout() {
    simulateError(1, read_request_timeout);

    try {
      query();
      fail("expected a ReadTimeoutException");
    } catch (ReadTimeoutException e) {
      /*expected*/
    }

    assertOnReadTimeoutWasCalled(1);
    assertThat(errors.getReadTimeouts().getCount()).isEqualTo(1);
    assertThat(errors.getRetries().getCount()).isEqualTo(0);
    assertThat(errors.getRetriesOnReadTimeout().getCount()).isEqualTo(0);
    assertQueried(1, 1);
    assertQueried(2, 0);
    assertQueried(3, 0);
  }

  /**
   * Ensures that when handling a write timeout with {@link DowngradingConsistencyRetryPolicy} that
   * it rethrows the exception if the {@link WriteType} was any of {@link #rethrowWriteTypes}.
   *
   * @param writeType writeType communicated by {@link WriteTimeoutException}.
   * @test_category retry_policy
   */
  @Test(groups = "short", dataProvider = "rethrowWriteTypes")
  public void should_rethrow_on_write_timeout_with_write_type(WriteTypePrime writeType) {
    simulateError(1, write_request_timeout, new WriteTimeoutConfig(writeType, 0, 2));

    try {
      query();
      fail("expected a WriteTimeoutException");
    } catch (WriteTimeoutException e) {
      /*expected*/
    }

    assertOnWriteTimeoutWasCalled(1);
    assertThat(errors.getWriteTimeouts().getCount()).isEqualTo(1);
    assertThat(errors.getRetries().getCount()).isEqualTo(0);
    assertThat(errors.getRetriesOnWriteTimeout().getCount()).isEqualTo(0);
    assertQueried(1, 1);
    assertQueried(2, 0);
    assertQueried(3, 0);
  }

  /**
   * Ensures that when handling a write timeout with {@link DowngradingConsistencyRetryPolicy} that
   * it ignores the exception if the {@link WriteType} was any of {@link
   * #ignoreWriteTypesWithReceivedAcks} and we received acks some at least one replica.
   *
   * @param writeType writeType communicated by {@link WriteTimeoutException}.
   * @test_category retry_policy
   */
  @Test(groups = "short", dataProvider = "ignoreWriteTypesWithReceivedAcks")
  public void should_ignore_on_write_timeout_with_write_type_and_received_acks(
      WriteTypePrime writeType) {
    simulateError(1, write_request_timeout, new WriteTimeoutConfig(writeType, 1, 2));

    query();

    assertOnWriteTimeoutWasCalled(1);
    assertThat(errors.getWriteTimeouts().getCount()).isEqualTo(1);
    assertThat(errors.getRetries().getCount()).isEqualTo(0);
    assertThat(errors.getRetriesOnWriteTimeout().getCount()).isEqualTo(0);
    assertThat(errors.getIgnoresOnWriteTimeout().getCount()).isEqualTo(1);
    assertQueried(1, 1);
    assertQueried(2, 0);
    assertQueried(3, 0);
  }

  /**
   * Ensures that when handling a write timeout with {@link DowngradingConsistencyRetryPolicy} that
   * a retry is attempted on the same host if the {@link WriteType} is {@link WriteType#BATCH_LOG}.
   *
   * @test_category retry_policy
   */
  @Test(groups = "short")
  public void should_retry_once_on_same_host_with_BATCH_LOG_write_type() {
    simulateError(1, write_request_timeout, new WriteTimeoutConfig(WriteTypePrime.BATCH_LOG, 1, 2));

    try {
      query();
      fail("expected a WriteTimeoutException");
    } catch (WriteTimeoutException e) {
      /*expected*/
    }

    assertOnWriteTimeoutWasCalled(2);
    assertThat(errors.getRetries().getCount()).isEqualTo(1);
    assertThat(errors.getWriteTimeouts().getCount()).isEqualTo(2);
    assertThat(errors.getRetriesOnWriteTimeout().getCount()).isEqualTo(1);
    assertQueried(1, 2);
    assertQueried(2, 0);
    assertQueried(3, 0);
  }

  /**
   * Ensures that when handling a write timeout with {@link DowngradingConsistencyRetryPolicy} that
   * a retry is attempted on the same host with a reduced consistency level that matches
   * min(received acknowledgments, THREE) if the {@link WriteType} is {@link
   * WriteType#UNLOGGED_BATCH} and is only retries once.
   *
   * @param alive The number of received acknowledgements to use in write timeout.
   * @param expectedDowngradedCL The consistency level that is expected to be used on the retry.
   * @test_category retry_policy
   */
  @Test(groups = "short", dataProvider = "consistencyLevels")
  public void should_retry_once_on_same_host_with_reduced_consistency_level_on_write_timeout(
      int alive, ConsistencyLevel expectedDowngradedCL) {
    simulateError(
        1, write_request_timeout, new WriteTimeoutConfig(UNLOGGED_BATCH, alive, alive + 1));

    try {
      query();
      fail("expected a WriteTimeoutException");
    } catch (WriteTimeoutException e) {
      assertThat(e.getConsistencyLevel()).isEqualTo(expectedDowngradedCL);
    }

    assertOnWriteTimeoutWasCalled(2);
    assertThat(errors.getRetries().getCount()).isEqualTo(1);
    assertThat(errors.getWriteTimeouts().getCount()).isEqualTo(2);
    assertThat(errors.getRetriesOnWriteTimeout().getCount()).isEqualTo(1);
    assertQueried(1, 2);
    assertQueried(2, 0);
    assertQueried(3, 0);
  }

  /**
   * Ensures that when handling an unavailable with {@link DowngradingConsistencyRetryPolicy} that a
   * retry is reattempted with a {@link ConsistencyLevel} that matches min(received
   * acknowledgements, THREE) and is only retried once.
   *
   * @param alive The number of received acknowledgements to use in unavailable.
   * @param expectedDowngradedCL The consistency level that is expected to be used on the retry.
   * @test_category retry_policy
   */
  @Test(groups = "short", dataProvider = "consistencyLevels")
  public void should_retry_once_on_same_host_with_reduced_consistency_level_on_unavailable(
      int alive, ConsistencyLevel expectedDowngradedCL) {
    simulateError(1, unavailable, new UnavailableConfig(alive + 1, alive));

    try {
      query();
      fail("expected an UnavailableException");
    } catch (UnavailableException e) {
      assertThat(e.getConsistencyLevel()).isEqualTo(expectedDowngradedCL);
    }

    assertOnUnavailableWasCalled(2);
    assertThat(errors.getRetries().getCount()).isEqualTo(1);
    assertThat(errors.getUnavailables().getCount()).isEqualTo(2);
    assertThat(errors.getRetriesOnUnavailable().getCount()).isEqualTo(1);
    assertQueried(1, 2);
    assertQueried(2, 0);
    assertQueried(3, 0);
  }

  /**
   * Ensures that when handling an unavailable with {@link DowngradingConsistencyRetryPolicy} that a
   * retry is is not reattempted if no replicas are alive.
   *
   * @test_category retry_policy
   */
  @Test(groups = "short")
  public void should_rethrow_if_no_hosts_alive_on_unavailable() {
    simulateError(1, unavailable, new UnavailableConfig(1, 0));

    try {
      query();
      fail("expected an UnavailableException");
    } catch (UnavailableException e) {
      /*expected*/
    }

    assertOnUnavailableWasCalled(1);
    assertThat(errors.getRetries().getCount()).isEqualTo(0);
    assertThat(errors.getUnavailables().getCount()).isEqualTo(1);
    assertThat(errors.getRetriesOnUnavailable().getCount()).isEqualTo(0);
    assertQueried(1, 1);
    assertQueried(2, 0);
    assertQueried(3, 0);
  }

  /**
   * Ensures that when handling a client timeout with {@link DowngradingConsistencyRetryPolicy} that
   * a retry is attempted on the next host until all hosts are tried at which point a {@link
   * NoHostAvailableException} is returned.
   *
   * @test_category retry_policy
   */
  @Test(groups = "short")
  public void should_try_next_host_on_client_timeouts() {
    cluster.getConfiguration().getSocketOptions().setReadTimeoutMillis(1);
    try {
      scassandras
          .node(1)
          .primingClient()
          .prime(
              PrimingRequest.queryBuilder()
                  .withQuery("mock query")
                  .withThen(then().withFixedDelay(1000L).withRows(row("result", "result1")))
                  .build());
      scassandras
          .node(2)
          .primingClient()
          .prime(
              PrimingRequest.queryBuilder()
                  .withQuery("mock query")
                  .withThen(then().withFixedDelay(1000L).withRows(row("result", "result2")))
                  .build());
      scassandras
          .node(3)
          .primingClient()
          .prime(
              PrimingRequest.queryBuilder()
                  .withQuery("mock query")
                  .withThen(then().withFixedDelay(1000L).withRows(row("result", "result3")))
                  .build());
      try {
        query();
        Assertions.fail("expected a NoHostAvailableException");
      } catch (NoHostAvailableException e) {
        assertThat(e.getErrors().keySet())
            .hasSize(3)
            .containsOnly(host1.getEndPoint(), host2.getEndPoint(), host3.getEndPoint());
        assertThat(e.getErrors().values())
            .hasOnlyElementsOfType(OperationTimedOutException.class)
            .extractingResultOf("getMessage")
            .containsOnlyOnce(
                String.format("[%s] Timed out waiting for server response", host1.getEndPoint()),
                String.format("[%s] Timed out waiting for server response", host2.getEndPoint()),
                String.format("[%s] Timed out waiting for server response", host3.getEndPoint()));
      }
      assertOnRequestErrorWasCalled(3, OperationTimedOutException.class);
      assertThat(errors.getRetries().getCount()).isEqualTo(3);
      assertThat(errors.getClientTimeouts().getCount()).isEqualTo(3);
      assertThat(errors.getRetriesOnClientTimeout().getCount()).isEqualTo(3);
      assertQueried(1, 1);
      assertQueried(2, 1);
      assertQueried(3, 1);
    } finally {
      cluster
          .getConfiguration()
          .getSocketOptions()
          .setReadTimeoutMillis(SocketOptions.DEFAULT_READ_TIMEOUT_MILLIS);
    }
  }

  /**
   * Ensures that when handling a server error defined in {@link #serverSideErrors} with {@link
   * DowngradingConsistencyRetryPolicy} that a retry is attempted on the next host until all hosts
   * are tried at which point a {@link NoHostAvailableException} is raised and it's errors include
   * the expected exception.
   *
   * @param error Server side error to be produced.
   * @param exception The exception we expect to be raised.
   * @test_category retry_policy
   */
  @Test(groups = "short", dataProvider = "serverSideErrors")
  public void should_try_next_host_on_server_side_error(
      Result error, Class<? extends DriverException> exception) {
    simulateError(1, error);
    simulateError(2, error);
    simulateError(3, error);
    try {
      query();
      Fail.fail("expected a NoHostAvailableException");
    } catch (NoHostAvailableException e) {
      assertThat(e.getErrors().keySet())
          .hasSize(3)
          .containsOnly(host1.getEndPoint(), host2.getEndPoint(), host3.getEndPoint());
      assertThat(e.getErrors().values()).hasOnlyElementsOfType(exception);
    }
    assertOnRequestErrorWasCalled(3, exception);
    assertThat(errors.getOthers().getCount()).isEqualTo(3);
    assertThat(errors.getRetries().getCount()).isEqualTo(3);
    assertThat(errors.getRetriesOnOtherErrors().getCount()).isEqualTo(3);
    assertQueried(1, 1);
    assertQueried(2, 1);
    assertQueried(3, 1);
  }

  /**
   * Ensures that when handling a connection error caused by the connection closing during a request
   * in a way described by {@link #connectionErrors} that the next host is tried.
   *
   * @param closeType The way the connection should be closed during the request.
   */
  @Test(groups = "short", dataProvider = "connectionErrors")
  public void should_try_next_host_on_connection_error(ClosedConnectionConfig.CloseType closeType) {
    simulateError(1, closed_connection, new ClosedConnectionConfig(closeType));
    simulateError(2, closed_connection, new ClosedConnectionConfig(closeType));
    simulateError(3, closed_connection, new ClosedConnectionConfig(closeType));
    try {
      query();
      Fail.fail("expected a TransportException");
    } catch (NoHostAvailableException e) {
      assertThat(e.getErrors().keySet())
          .hasSize(3)
          .containsOnly(host1.getEndPoint(), host2.getEndPoint(), host3.getEndPoint());
      assertThat(e.getErrors().values()).hasOnlyElementsOfType(TransportException.class);
    }
    assertOnRequestErrorWasCalled(3, TransportException.class);
    assertThat(errors.getRetries().getCount()).isEqualTo(3);
    assertThat(errors.getConnectionErrors().getCount()).isEqualTo(3);
    assertThat(errors.getIgnoresOnConnectionError().getCount()).isEqualTo(0);
    assertThat(errors.getRetriesOnConnectionError().getCount()).isEqualTo(3);
    assertQueried(1, 1);
    assertQueried(2, 1);
    assertQueried(3, 1);
  }

  @Test(groups = "short")
  public void should_rethrow_on_unavailable_if_CAS() {
    simulateError(1, unavailable, new UnavailableConfig(1, 0, SERIAL));
    simulateError(2, unavailable, new UnavailableConfig(1, 0, SERIAL));

    try {
      query();
      fail("expected an UnavailableException");
    } catch (UnavailableException e) {
      assertThat(e.getConsistencyLevel()).isEqualTo(ConsistencyLevel.SERIAL);
    }

    assertOnUnavailableWasCalled(2);
    assertThat(errors.getRetries().getCount()).isEqualTo(1);
    assertThat(errors.getUnavailables().getCount()).isEqualTo(2);
    assertThat(errors.getRetriesOnUnavailable().getCount()).isEqualTo(1);
    assertQueried(1, 1);
    assertQueried(2, 1);
    assertQueried(3, 0);
  }

  @Test(groups = "short")
  public void should_rethrow_on_read_failure() {
    simulateError(1, read_failure);

    try {
      query();
      fail("expected a ReadFailureException");
    } catch (DriverException e) {
      assertThat(e).isInstanceOf(ReadFailureException.class);
    }

    assertOnRequestErrorWasCalled(1, ReadFailureException.class);
    assertThat(errors.getOthers().getCount()).isEqualTo(1);
    assertThat(errors.getRetries().getCount()).isEqualTo(0);
    assertThat(errors.getRetriesOnOtherErrors().getCount()).isEqualTo(0);
    assertQueried(1, 1);
    assertQueried(2, 0);
    assertQueried(3, 0);
  }

  @Test(groups = "short")
  public void should_rethrow_on_write_failure() {
    simulateError(1, write_failure);

    try {
      query();
      fail("expected a WriteFailureException");
    } catch (DriverException e) {
      assertThat(e).isInstanceOf(WriteFailureException.class);
    }

    assertOnRequestErrorWasCalled(1, WriteFailureException.class);
    assertThat(errors.getOthers().getCount()).isEqualTo(1);
    assertThat(errors.getRetries().getCount()).isEqualTo(0);
    assertThat(errors.getRetriesOnOtherErrors().getCount()).isEqualTo(0);
    assertQueried(1, 1);
    assertQueried(2, 0);
    assertQueried(3, 0);
  }
}
