/*
 * Copyright 2015, The OpenNMS Group
 * 
 * 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 org.opennms.newts.persistence.cassandra;


import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.Timer;
import com.datastax.driver.core.BoundStatement;
import com.datastax.driver.core.PreparedStatement;
import com.datastax.driver.core.ResultSet;
import com.datastax.driver.core.querybuilder.Batch;
import com.datastax.driver.core.querybuilder.Delete;
import com.datastax.driver.core.querybuilder.Insert;
import com.datastax.driver.core.querybuilder.QueryBuilder;
import com.datastax.driver.core.querybuilder.Select;
import com.google.common.base.Optional;
import com.google.common.base.Throwables;
import com.google.common.collect.Lists;
import org.opennms.newts.aggregate.IntervalGenerator;
import org.opennms.newts.aggregate.ResultProcessor;
import org.opennms.newts.api.Context;
import org.opennms.newts.api.Duration;
import org.opennms.newts.api.Measurement;
import org.opennms.newts.api.Resource;
import org.opennms.newts.api.Results;
import org.opennms.newts.api.Results.Row;
import org.opennms.newts.api.Sample;
import org.opennms.newts.api.SampleProcessorService;
import org.opennms.newts.api.SampleRepository;
import org.opennms.newts.api.SampleSelectCallback;
import org.opennms.newts.api.Timestamp;
import org.opennms.newts.api.ValueType;
import org.opennms.newts.api.query.ResultDescriptor;
import org.opennms.newts.cassandra.CassandraSession;
import org.opennms.newts.cassandra.ContextConfigurations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Inject;
import javax.inject.Named;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

import static com.codahale.metrics.MetricRegistry.name;
import static com.datastax.driver.core.querybuilder.QueryBuilder.bindMarker;
import static com.datastax.driver.core.querybuilder.QueryBuilder.eq;
import static com.datastax.driver.core.querybuilder.QueryBuilder.gte;
import static com.datastax.driver.core.querybuilder.QueryBuilder.insertInto;
import static com.datastax.driver.core.querybuilder.QueryBuilder.lte;
import static com.datastax.driver.core.querybuilder.QueryBuilder.ttl;
import static com.datastax.driver.core.querybuilder.QueryBuilder.unloggedBatch;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;


public class CassandraSampleRepository implements SampleRepository {

    private static final Logger LOG = LoggerFactory.getLogger(CassandraSampleRepository.class);

    // Used to calculate the duration when the duration is not specified
    private static final int TARGET_NUMBER_OF_STEPS = 10;
    private static final int DELETION_INTERVAL = 360;

    private final CassandraSession m_session;
    private final int m_ttl;
    private final SampleProcessorService m_processorService;
    private final PreparedStatement m_selectStatement;
    private final PreparedStatement m_deleteStatement;

    private final Timer m_sampleSelectTimer;
    private final Timer m_measurementSelectTimer;
    private final Timer m_insertTimer;
    private final Meter m_samplesInserted;
    private final Meter m_samplesSelected;

    private final ContextConfigurations m_contextConfigurations;

    @Inject
    public CassandraSampleRepository(CassandraSession session, @Named("samples.cassandra.time-to-live") int ttl, @Named("newtsMetricRegistry") MetricRegistry registry,
                                     SampleProcessorService processorService, ContextConfigurations contextConfigurations) {

        m_session = checkNotNull(session, "session argument");
        checkArgument(ttl >= 0, "Negative Cassandra column TTL");

        m_ttl = ttl;

        checkNotNull(registry, "metric registry argument");
        m_processorService = processorService;

        m_contextConfigurations = checkNotNull(contextConfigurations, "contextConfigurations argument");

        Select select = QueryBuilder.select().from(SchemaConstants.T_SAMPLES);
        select.where(eq(SchemaConstants.F_CONTEXT, bindMarker(SchemaConstants.F_CONTEXT)));
        select.where(eq(SchemaConstants.F_PARTITION, bindMarker(SchemaConstants.F_PARTITION)));
        select.where(eq(SchemaConstants.F_RESOURCE, bindMarker(SchemaConstants.F_RESOURCE)));

        select.where(gte(SchemaConstants.F_COLLECTED, bindMarker("start")));
        select.where(lte(SchemaConstants.F_COLLECTED, bindMarker("end")));

        m_selectStatement = m_session.prepare(select.toString());

        Delete delete = QueryBuilder.delete().from(SchemaConstants.T_SAMPLES);
        delete.where(eq(SchemaConstants.F_CONTEXT, bindMarker(SchemaConstants.F_CONTEXT)));
        delete.where(eq(SchemaConstants.F_PARTITION, bindMarker(SchemaConstants.F_PARTITION)));
        delete.where(eq(SchemaConstants.F_RESOURCE, bindMarker(SchemaConstants.F_RESOURCE)));

        m_deleteStatement = m_session.prepare(delete.toString());

        m_sampleSelectTimer = registry.timer(metricName("sample-select-timer"));
        m_measurementSelectTimer = registry.timer(metricName("measurement-select-timer"));
        m_insertTimer = registry.timer(metricName("insert-timer"));
        m_samplesInserted = registry.meter(metricName("samples-inserted"));
        m_samplesSelected = registry.meter(metricName("samples-selected"));
    }

    public Iterable<Results.Row<Sample>> select(Context context, Resource resource, Timestamp start, Timestamp end, ResultDescriptor descriptor, Duration step) {
        return new DriverAdapter(cassandraSelect(context, resource, start.minus(step), end),
                descriptor.getSourceNames());
    }

    @Override
    public Results<Measurement> select(Context context, Resource resource, Optional<Timestamp> start, Optional<Timestamp> end, ResultDescriptor descriptor, Optional<Duration> resolution) {
        return select(context, resource, start, end, descriptor, resolution, noopSampleSelectCallback);
    }

    @Override
    public Results<Measurement> select(Context context, Resource resource, Optional<Timestamp> start, Optional<Timestamp> end, ResultDescriptor descriptor, Optional<Duration> resolution, SampleSelectCallback callback) {

        Timer.Context timer = m_measurementSelectTimer.time();

        validateSelect(start, end);

        Timestamp upper = end.isPresent() ? end.get() : Timestamp.now();
        Timestamp lower = start.isPresent() ? start.get() : upper.minus(Duration.seconds(86400));
        Duration step;
        if (resolution.isPresent()) {
            step = resolution.get();
        } else {
            // Determine the ideal step size, splitting the interval evenly into N slices
            long stepMillis = upper.minus(lower).asMillis() / TARGET_NUMBER_OF_STEPS;

            // But every step must be a multiple of the interval
            long intervalMillis = descriptor.getInterval().asMillis();

            // If the interval is greater than the target step, use the 2 * interval as the step
            if (intervalMillis >= stepMillis) {
                step = descriptor.getInterval().times(2);
            } else {
                // Otherwise, round stepMillkeyis up to the closest multiple of intervalMillis
                long remainderMillis = stepMillis % intervalMillis;
                if (remainderMillis != 0) {
                    stepMillis = stepMillis + intervalMillis - remainderMillis;
                }

                step = Duration.millis(stepMillis);
            }
        }

        LOG.debug("Querying database for resource {}, from {} to {}", resource, lower.minus(step), upper);

        DriverAdapter driverAdapter = new DriverAdapter(cassandraSelect(context, resource, lower.minus(step), upper),
                    descriptor.getSourceNames());

        Results<Measurement> results;
        callback.beforeProcess();
        try {
            results = new ResultProcessor(resource, lower, upper, descriptor, step).process(driverAdapter);
        } finally {
            callback.afterProcess();
        }

        LOG.debug("{} results returned from database", driverAdapter.getResultCount());
        m_samplesSelected.mark(driverAdapter.getResultCount());

        try {
            return results;
        } finally {
            timer.stop();
        }

    }

    @Override
    public Results<Sample> select(Context context, Resource resource, Optional<Timestamp> start, Optional<Timestamp> end) {

        Timer.Context timer = m_sampleSelectTimer.time();

        validateSelect(start, end);

        Timestamp upper = end.isPresent() ? end.get() : Timestamp.now();
        Timestamp lower = start.isPresent() ? start.get() : upper.minus(Duration.seconds(86400));

        LOG.debug("Querying database for resource {}, from {} to {}", resource, lower, upper);

        Results<Sample> samples = new Results<>();
        DriverAdapter driverAdapter = new DriverAdapter(cassandraSelect(context, resource, lower, upper));

        for (Row<Sample> row : driverAdapter) {
            samples.addRow(row);
        }

        LOG.debug("{} results returned from database", driverAdapter.getResultCount());
        m_samplesSelected.mark(driverAdapter.getResultCount());

        try {
            return samples;
        } finally {
            timer.stop();
        }
    }

    @Override
    public void insert(Collection<Sample> samples) {
        insert(samples, false);
    }

    @Override
    public void insert(Collection<Sample> samples, boolean calculateTimeToLive) {

        Timer.Context timer = m_insertTimer.time();
        Timestamp now = Timestamp.now();

        Batch batch = unloggedBatch();

        for (Sample m : samples) {
            int ttl = m_ttl;
            if (calculateTimeToLive) {
                ttl -= (int) (now.asSeconds() - m.getTimestamp().asSeconds());
                if (ttl <= 0) {
                    LOG.debug("Skipping expired sample: {}", m);
                    continue;
                }
            }

            Duration resourceShard = m_contextConfigurations.getResourceShard(m.getContext());

            Insert insert = insertInto(SchemaConstants.T_SAMPLES)
                    .value(SchemaConstants.F_CONTEXT, m.getContext().getId())
                    .value(SchemaConstants.F_PARTITION, m.getTimestamp().stepFloor(resourceShard).asSeconds())
                    .value(SchemaConstants.F_RESOURCE, m.getResource().getId())
                    .value(SchemaConstants.F_COLLECTED, m.getTimestamp().asMillis())
                    .value(SchemaConstants.F_METRIC_NAME, m.getName())
                    .value(SchemaConstants.F_VALUE, ValueType.decompose(m.getValue()));

            // Inserting a column with a null value inserts a tombstone (a deletion marker); Skip the attributes
            // for any sample that has not specified them.
            if (m.getAttributes() != null) {
                insert.value(SchemaConstants.F_ATTRIBUTES, m.getAttributes());
            }

            // Use the context specific consistency level
            insert.setConsistencyLevel(m_contextConfigurations.getWriteConsistency(m.getContext()));

            batch.add(insert.using(ttl(ttl)));
        }

        try {
            m_session.execute(batch);

            if (m_processorService != null) {
                m_processorService.submit(samples);
            }

            m_samplesInserted.mark(samples.size());
        } finally {
            timer.stop();
        }
    }

    @Override
    public void delete(Context context, Resource resource) {
        /**
         * Check for ttl value > 0
         */
        if (m_ttl > 0) {
            /**
             * Delete exactly from (now - ttl) till now
             */
            final Timestamp start = Timestamp.now().minus(m_ttl, TimeUnit.SECONDS);
            final Timestamp end = Timestamp.now();

            final Duration resourceShard = m_contextConfigurations.getResourceShard(context);

            final List<Future<ResultSet>> futures = Lists.newArrayList();
            for (Timestamp partition : new IntervalGenerator(start.stepFloor(resourceShard),
                    end.stepFloor(resourceShard),
                    resourceShard)) {
                BoundStatement bindStatement = m_deleteStatement.bind();
                bindStatement.setString(SchemaConstants.F_CONTEXT, context.getId());
                bindStatement.setInt(SchemaConstants.F_PARTITION, (int) partition.asSeconds());
                bindStatement.setString(SchemaConstants.F_RESOURCE, resource.getId());

                futures.add(m_session.executeAsync(bindStatement));
            }

            for (final Future<ResultSet> future : futures) {
                try {
                    future.get();
                } catch (final InterruptedException | ExecutionException e) {
                    throw Throwables.propagate(e);
                }
            }
        } else {
            // Choose (now - one year) till now...
            Timestamp end = Timestamp.now();
            Timestamp start = end.minus(DELETION_INTERVAL, TimeUnit.DAYS);

            // ... and check whether samples exist for this period of time.
            while (cassandraSelect(context, resource, start, end).hasNext()) {
                // Now delete the samples...
                final Duration resourceShard = m_contextConfigurations.getResourceShard(context);

                final List<Future<ResultSet>> futures = Lists.newArrayList();
                for (Timestamp partition : new IntervalGenerator(start.stepFloor(resourceShard),
                        end.stepFloor(resourceShard),
                        resourceShard)) {
                    BoundStatement bindStatement = m_deleteStatement.bind();
                    bindStatement.setString(SchemaConstants.F_CONTEXT, context.getId());
                    bindStatement.setInt(SchemaConstants.F_PARTITION, (int) partition.asSeconds());
                    bindStatement.setString(SchemaConstants.F_RESOURCE, resource.getId());

                    futures.add(m_session.executeAsync(bindStatement));
                }

                for (final Future<ResultSet> future : futures) {
                    try {
                        future.get();
                    } catch (final InterruptedException | ExecutionException e) {
                        throw Throwables.propagate(e);
                    }
                }

                // ...set end to start and start to (end - one year)
                end = start;
                start = end.minus(DELETION_INTERVAL, TimeUnit.DAYS);

                // and start over again until no more samples are found
            }
        }
    }

    private Iterator<com.datastax.driver.core.Row> cassandraSelect(Context context, Resource resource,
                                                                   Timestamp start, Timestamp end) {

        List<Future<ResultSet>> futures = Lists.newArrayList();

        Duration resourceShard = m_contextConfigurations.getResourceShard(context);
        Timestamp lower = start.stepFloor(resourceShard);
        Timestamp upper = end.stepFloor(resourceShard);

        for (Timestamp partition : new IntervalGenerator(lower, upper, resourceShard)) {
            BoundStatement bindStatement = m_selectStatement.bind();
            bindStatement.setString(SchemaConstants.F_CONTEXT, context.getId());
            bindStatement.setInt(SchemaConstants.F_PARTITION, (int) partition.asSeconds());
            bindStatement.setString(SchemaConstants.F_RESOURCE, resource.getId());
            bindStatement.setTimestamp("start", start.asDate());
            bindStatement.setTimestamp("end", end.asDate());
            // Use the context specific consistency level
            bindStatement.setConsistencyLevel(m_contextConfigurations.getReadConsistency(context));

            futures.add(m_session.executeAsync(bindStatement));
        }

        return new ConcurrentResultWrapper(futures);
    }

    private void validateSelect(Optional<Timestamp> start, Optional<Timestamp> end) {
        if ((start.isPresent() && end.isPresent()) && start.get().gt(end.get())) {
            throw new IllegalArgumentException("start time must be less than end time");
        }
    }

    private String metricName(String suffix) {
        return name("repository", suffix);
    }

    private static final SampleSelectCallback noopSampleSelectCallback = new SampleSelectCallback() {

        @Override
        public void beforeProcess() {
            // pass
        }

        @Override
        public void afterProcess() {
            // pass
        }
    };
}