/*
 * Copyright © 2014 Cask Data, Inc.
 *
 * 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.cdap.cdap.api.dataset.lib;

import io.cdap.cdap.api.common.Bytes;
import io.cdap.cdap.api.dataset.DatasetProperties;
import io.cdap.cdap.data2.dataset2.DatasetFrameworkTestUtil;
import io.cdap.cdap.proto.id.DatasetId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.apache.tephra.TransactionExecutor;
import org.apache.tephra.TransactionFailureException;
import org.junit.AfterClass;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;

/**
 * Time series table tests.
 */
public class TimeseriesTableTest {
  @ClassRule
  public static DatasetFrameworkTestUtil dsFrameworkUtil = new DatasetFrameworkTestUtil();

  private static final DatasetId metricsTableInstance = DatasetFrameworkTestUtil.NAMESPACE_ID.dataset("metricsTable");
  private static TimeseriesTable table;

  @BeforeClass
  public static void beforeClass() throws Exception {
    dsFrameworkUtil.createInstance("timeseriesTable", metricsTableInstance, DatasetProperties.EMPTY);
    table = dsFrameworkUtil.getInstance(metricsTableInstance);
  }

  @AfterClass
  public static void afterClass() throws Exception {
    dsFrameworkUtil.deleteInstance(metricsTableInstance);
  }

  @Test
  public void testDataSet() throws Exception {

    TransactionExecutor txnl = dsFrameworkUtil.newTransactionExecutor(table);

    // this test runs all operations synchronously
    txnl.execute(new TransactionExecutor.Subroutine() {
      @Override
      public void apply() throws Exception {
        byte[] metric1 = Bytes.toBytes("metric1");
        byte[] metric2 = Bytes.toBytes("metric2");
        byte[] tag1 = Bytes.toBytes("111");
        byte[] tag2 = Bytes.toBytes("22");
        byte[] tag3 = Bytes.toBytes("3");
        byte[] tag4 = Bytes.toBytes("123");

        long hour = TimeUnit.HOURS.toMillis(1);
        long second = TimeUnit.SECONDS.toMillis(1);

        long ts = System.currentTimeMillis();

        // m1e1 = metric: 1, entity: 1
        TimeseriesTable.Entry m1e1 = new TimeseriesTable.Entry(metric1, Bytes.toBytes(3L), ts, tag3, tag2, tag1);
        table.write(m1e1);
        TimeseriesTable.Entry m1e2 = new TimeseriesTable.Entry(metric1, Bytes.toBytes(10L), ts + 2 * second, tag3);
        table.write(m1e2);
        TimeseriesTable.Entry m1e3 = new TimeseriesTable.Entry(metric1, Bytes.toBytes(15L), ts + 2 * hour, tag1);
        table.write(m1e3);
        TimeseriesTable.Entry m1e4 = new TimeseriesTable.Entry(metric1, Bytes.toBytes(23L), ts + 3 * hour, tag2, tag3);
        table.write(m1e4);
        TimeseriesTable.Entry m1e5 = new TimeseriesTable.Entry(metric1, Bytes.toBytes(55L), ts + 3 * hour + 2 * second);
        table.write(m1e5);

        TimeseriesTable.Entry m2e1 = new TimeseriesTable.Entry(metric2, Bytes.toBytes(4L), ts);
        table.write(m2e1);
        TimeseriesTable.Entry m2e2 = new TimeseriesTable.Entry(metric2, Bytes.toBytes(11L), ts + 2 * second, tag2);
        table.write(m2e2);
        TimeseriesTable.Entry m2e3 = new TimeseriesTable.Entry(metric2, Bytes.toBytes(16L), ts + 2 * hour, tag2);
        table.write(m2e3);
        TimeseriesTable.Entry m2e4 = new TimeseriesTable.Entry(metric2, Bytes.toBytes(24L), ts + 3 * hour, tag1, tag3);
        table.write(m2e4);
        TimeseriesTable.Entry m2e5 = new TimeseriesTable.Entry(metric2, Bytes.toBytes(56L), ts + 3 * hour + 2 * second,
                                                               tag3, tag1);
        table.write(m2e5);

        // whole interval is searched
        assertReadResult(table.read(metric1, ts, ts + 5 * hour), m1e1, m1e2, m1e3, m1e4, m1e5);
        assertReadResult(table.read(metric1, ts, ts + 5 * hour, tag2), m1e1, m1e4);
        assertReadResult(table.read(metric1, ts, ts + 5 * hour, tag4));
        assertReadResult(table.read(metric1, ts, ts + 5 * hour, tag2, tag4));
        // This is extreme case, should not be really used by anyone. Still we want to test that it won't fail.
        // It returns nothing because there's hard limit on the number of rows traversed during the read.
        assertReadResult(table.read(metric1, 0, Long.MAX_VALUE));

        // test pagination read
        assertReadResult(table.read(metric1, ts, ts + 5 * hour, 1, 2), m1e2, m1e3);

        // part of the interval
        assertReadResult(table.read(metric1, ts + second, ts + 2 * second), m1e2);
        assertReadResult(table.read(metric1, ts + hour, ts + 3 * hour), m1e3, m1e4);
        assertReadResult(table.read(metric1, ts + second, ts + 3 * hour), m1e2, m1e3, m1e4);
        assertReadResult(table.read(metric1, ts + second, ts + 3 * hour, tag3), m1e2, m1e4);
        assertReadResult(table.read(metric1, ts + second, ts + 3 * hour, tag3, tag2), m1e4);

        // different metric
        assertReadResult(table.read(metric2, ts + hour, ts + 3 * hour, tag2), m2e3);
      }
    });
  }

  @Test(expected = TransactionFailureException.class)
  public void testInvalidTimeRangeCondition() throws Exception {
    TransactionExecutor txnl = dsFrameworkUtil.newTransactionExecutor(table);
    txnl.execute(new TransactionExecutor.Subroutine() {
      @Override
      public void apply() throws Exception {
        long ts = System.currentTimeMillis();
        table.read(Bytes.toBytes("any"), ts, ts - 100);
      }
    });
  }

  @Test
  public void testValidTimeRangesAreAllowed() throws Exception {
    TransactionExecutor txnl = dsFrameworkUtil.newTransactionExecutor(table);
    txnl.execute(new TransactionExecutor.Subroutine() {
      @Override
      public void apply() throws Exception {
        long ts = System.currentTimeMillis();
        Iterator<TimeseriesTable.Entry> temp = table.read(Bytes.toBytes("any"), ts, ts);
        Assert.assertFalse(temp.hasNext());
        temp = table.read(Bytes.toBytes("any"), ts, ts + 100);
        Assert.assertFalse(temp.hasNext());
      }
    });
  }

  // TODO: test for wrong params: end time less than start time
  private void assertReadResult(Iterator<TimeseriesTable.Entry> resultIterator, TimeseriesTable.Entry... entries) {
    int count = 0;
    while (resultIterator.hasNext()) {

      assertEquals(entries[count], resultIterator.next());
      count++;
    }
    Assert.assertEquals(entries.length, count);
  }

  private void assertEquals(final TimeseriesTable.Entry left, final TimeseriesTable.Entry right) {
    Assert.assertArrayEquals(left.getKey(), right.getKey());
    Assert.assertEquals(left.getTimestamp(), right.getTimestamp());
    Assert.assertArrayEquals(left.getValue(), right.getValue());
    assertEqualsIgnoreOrder(left.getTags(), right.getTags());
  }

  private void assertEqualsIgnoreOrder(final byte[][] left, final byte[][] right) {
    Arrays.sort(left, Bytes.BYTES_COMPARATOR);
    Arrays.sort(right, Bytes.BYTES_COMPARATOR);
    Assert.assertEquals(left.length, right.length);
    for (int i = 0; i < left.length; i++) {
      Assert.assertArrayEquals(left[i], right[i]);
    }
  }

  /**
   * Time series table format test.
   */
  @Test
  public void testColumnNameFormat() {
    // Note: tags are sorted lexographically
    byte[] tag1 = Bytes.toBytes("111");
    byte[] tag2 = Bytes.toBytes("22");
    byte[] tag3 = Bytes.toBytes("3");

    long ts = System.currentTimeMillis();
    byte[][] tags = { tag1, tag2, tag3 };
    byte[] columnName = TimeseriesTable.createColumnName(ts, tags);

    Assert.assertEquals(ts, TimeseriesTable.parseTimeStamp(columnName));
    Assert.assertTrue(TimeseriesTable.hasTags(columnName));
  }

  @Test
  public void testColumnNameFormatWithNoTags() {
    long ts = System.currentTimeMillis();
    byte[] columnName = TimeseriesTable.createColumnName(ts, new byte[0][]);
    Assert.assertEquals(ts, TimeseriesTable.parseTimeStamp(columnName));
    Assert.assertFalse(TimeseriesTable.hasTags(columnName));
  }

  @Test
  public void testRowFormat() {
    // 1 min
    long timeIntervalPerRow = TimeUnit.MINUTES.toMillis(1);
    byte[] key = Bytes.toBytes("key");

    long ts1 = timeIntervalPerRow + 1;

    // These are the things we care about:

    // * If the timestamps fall into same time interval then row keys are same
    Assert.assertArrayEquals(TimeseriesTable.createRow(key, ts1, timeIntervalPerRow),
                             TimeseriesTable.createRow(key, ts1 + timeIntervalPerRow / 2, timeIntervalPerRow));
    // * If the timestamps don't fall into same time interval then row keys are different
    Assert.assertFalse(Arrays.equals(TimeseriesTable.createRow(key, ts1, timeIntervalPerRow),
                                     TimeseriesTable.createRow(key, ts1 + timeIntervalPerRow * 2, timeIntervalPerRow)));

    // * If timestamp A > timestamp B then row key A > row key B (we will have some optimization logic based on that)
    Assert.assertTrue(
      Bytes.compareTo(TimeseriesTable.createRow(key, ts1, timeIntervalPerRow),
                      TimeseriesTable.createRow(key, ts1 + timeIntervalPerRow * 5, timeIntervalPerRow)) < 0);

    // * For different keys the constructed rows are different
    byte[] key2 = Bytes.toBytes("KEY2");
    Assert.assertFalse(Arrays.equals(TimeseriesTable.createRow(key, ts1, timeIntervalPerRow),
                                     TimeseriesTable.createRow(key2, ts1, timeIntervalPerRow)));

    // * We can get all possible rows for a given time interval with getRowsForInterval
    List<byte[]> rows = new ArrayList<>();
    long startTime = timeIntervalPerRow * 4;
    long endTime = timeIntervalPerRow * 100;
    for (long i = startTime; i <= endTime; i += timeIntervalPerRow / 10) {
      byte[] row = TimeseriesTable.createRow(key, i, timeIntervalPerRow);
      if (rows.isEmpty() || !Arrays.equals(rows.get(rows.size() - 1), row)) {
        rows.add(row);
      }
    }

    int intervalsCount = (int) TimeseriesTable.getTimeIntervalsCount(startTime, endTime, timeIntervalPerRow);
    byte[][] rowsForInterval = new byte[intervalsCount][];
    for (int i = 0; i < intervalsCount; i++) {
      rowsForInterval[i] = TimeseriesTable.getRowOfKthInterval(key, startTime, i, timeIntervalPerRow);
    }
    assertEquals(rows.toArray(new byte[rows.size()][]), rowsForInterval);

    // same requirement, one more test (covers some bug I fixed)
    Assert.assertEquals(2, TimeseriesTable.getTimeIntervalsCount(timeIntervalPerRow + 1, timeIntervalPerRow * 2 + 1,
                                                                 timeIntervalPerRow));
  }

  private void assertEquals(final byte[][] left, final byte[][] right) {
    Assert.assertEquals(left.length, right.length);
    for (int i = 0; i < left.length; i++) {
      Assert.assertArrayEquals(left[i], right[i]);
    }
  }
}
