package io.paradoxical.cassieq.dataAccess;

import com.datastax.driver.core.Session;
import com.datastax.driver.core.Statement;
import com.datastax.driver.core.querybuilder.Clause;
import com.datastax.driver.core.querybuilder.Insert;
import com.datastax.driver.core.querybuilder.QueryBuilder;
import com.datastax.driver.core.querybuilder.Select;
import com.godaddy.logging.Logger;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import io.paradoxical.cassieq.clustering.eventing.EventBus;
import io.paradoxical.cassieq.dataAccess.interfaces.QueueRepository;
import io.paradoxical.cassieq.model.PointerType;
import io.paradoxical.cassieq.model.QueueDefinition;
import io.paradoxical.cassieq.model.QueueId;
import io.paradoxical.cassieq.model.QueueName;
import io.paradoxical.cassieq.model.QueueStatsId;
import io.paradoxical.cassieq.model.QueueStatus;
import io.paradoxical.cassieq.model.accounts.AccountName;
import io.paradoxical.cassieq.model.events.QueueAddedEvent;
import io.paradoxical.cassieq.model.events.QueueDeletingEvent;
import lombok.NonNull;

import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;

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.set;
import static com.godaddy.logging.LoggerFactory.getLogger;
import static java.util.stream.Collectors.toList;

public class QueueRepositoryImpl extends RepositoryBase implements QueueRepository {

    private static final Logger logger = getLogger(QueueRepositoryImpl.class);

    private final Session session;

    private final EventBus eventBus;

    private final AccountName accountName;

    @Inject
    public QueueRepositoryImpl(
            @NonNull final Session session,
            final EventBus eventBus,
            @NonNull
            @Assisted AccountName accountName) {
        this.session = session;
        this.accountName = accountName;
        this.eventBus = eventBus;
    }


    /**
     * Move the queue into an inoperable state and create a job
     *
     * @param definition
     * @return
     */
    @Override
    public Optional<DeletionJob> tryMarkForDeletion(final QueueDefinition definition) {

        // insert a deletion job that copies config
        final boolean markedForDeletion = tryAdvanceQueueStatus(definition.getQueueName(), QueueStatus.PendingDelete);

        if (markedForDeletion) {
            final Optional<DeletionJob> deletionJob = insertDeletionJobIfNotExists(definition);

            if (tryAdvanceQueueStatus(definition.getQueueName(), QueueStatus.Deleting)) {
                // anyone can create a queue with the same name now

                logger.with("definition", definition).success("Marked for deletion");

                eventBus.publish(new QueueDeletingEvent());

                return deletionJob;
            }
        }

        return Optional.empty();
    }

    @Override
    public Optional<Long> getQueueSize(final QueueDefinition definition) {
        final Statement where = QueryBuilder.select(Tables.QueueStats.SIZE)
                                            .from(Tables.QueueStats.TABLE_NAME)
                                            .where(eq(Tables.QueueStats.QUEUE_STATS_ID, definition.getQueueStatsId().get()));

        return Optional.ofNullable(getOne(session.execute(where), r -> r.getLong(Tables.QueueStats.SIZE)));
    }

    private Optional<DeletionJob> insertDeletionJobIfNotExists(final QueueDefinition definition) {
        final DeletionJob deletionJob = new DeletionJob(definition);

        final Statement insert = QueryBuilder.insertInto(Tables.DeletionJob.TABLE_NAME)
                                             .ifNotExists()
                                             .value(Tables.DeletionJob.QUEUE_NAME, deletionJob.getQueueName().get())
                                             .value(Tables.DeletionJob.ACCOUNT_NAME, accountName.get())
                                             .value(Tables.DeletionJob.VERSION, deletionJob.getVersion())
                                             .value(Tables.DeletionJob.QUEUE_STATS_ID, definition.getQueueStatsId().get())
                                             .value(Tables.DeletionJob.BUCKET_SIZE, deletionJob.getBucketSize().get());

        if (session.execute(insert).wasApplied()) {
            return Optional.of(deletionJob);
        }

        return Optional.empty();
    }

    @Override
    public Optional<QueueDefinition> createQueue(@NonNull final QueueDefinition definition) {
        if (tryUpsertQueueDefinition(definition)) {
            eventBus.publish(new QueueAddedEvent());

            return getActiveQueue(definition.getQueueName());
        }

        return Optional.empty();
    }

    /**
     * Set the status but only if its allowed to move to that status
     *
     * i.e. we MUST move gracefully between the states, no state jumping
     *
     * @param queueName
     * @param status
     */
    @Override
    public boolean tryAdvanceQueueStatus(
            @NonNull final QueueName queueName,
            @NonNull final QueueStatus status) {

        final Clause andIsPrevious = eq(Tables.Queue.STATUS, status.ordinal() - 1);
        final Clause orIsEqual = eq(Tables.Queue.STATUS, status.ordinal());

        Function<Clause, Boolean> applier = clause -> {
            final Statement update = QueryBuilder.update(Tables.Queue.TABLE_NAME)
                                                 .where(eq(Tables.Queue.QUEUE_NAME, queueName.get()))
                                                 .and(eq(Tables.Queue.ACCOUNT_NAME, accountName.get()))
                                                 .with(set(Tables.Queue.STATUS, status.ordinal()))
                                                 .onlyIf(clause);

            if (session.execute(update).wasApplied()) {
                logger.with("queue-name", queueName)
                      .with("new-status", status)
                      .success("Advancing queue status");

                return true;
            }

            return false;
        };

        if (applier.apply(andIsPrevious)) {
            return true;
        }

        return applier.apply(orIsEqual);
    }

    private boolean insertQueueIfNotExist(final QueueDefinition queueDefinition) {

        final QueueDefinition initDefinition = queueDefinition.toBuilder().version(0).build();

        final Insert insertQueue =
                QueryBuilder.insertInto(Tables.Queue.TABLE_NAME)
                            .ifNotExists()
                            .value(Tables.Queue.ACCOUNT_NAME, accountName.get())
                            .value(Tables.Queue.QUEUE_NAME, initDefinition.getQueueName().get())
                            .value(Tables.Queue.VERSION, initDefinition.getVersion())
                            .value(Tables.Queue.BUCKET_SIZE, initDefinition.getBucketSize().get())
                            .value(Tables.Queue.DELETE_BUCKETS_AFTER_FINALIZATION, initDefinition.getDeleteBucketsAfterFinalization())
                            .value(Tables.Queue.REPAIR_WORKER_POLL_FREQ_SECONDS, initDefinition.getRepairWorkerPollFrequencySeconds())
                            .value(Tables.Queue.REPAIR_WORKER_TOMBSTONE_BUCKET_TIMEOUT_SECONDS, initDefinition.getRepairWorkerTombstonedBucketTimeoutSeconds())
                            .value(Tables.Queue.QUEUE_STATS_ID, getUniqueQueueCounterId(initDefinition.getId()).get())
                            .value(Tables.Queue.MAX_DELIVERY_COUNT, initDefinition.getMaxDeliveryCount())
                            .value(Tables.Queue.DLQ_NAME, initDefinition.getDlqName().map(QueueName::get).orElse(null))
                            .value(Tables.Queue.STRICT_FIFO, initDefinition.isStrictFifo())
                            .value(Tables.Queue.STATUS, QueueStatus.Provisioning.ordinal());

        final boolean queueInserted = session.execute(insertQueue).wasApplied();

        if (queueInserted) {
            ensurePointers(queueDefinition);

            return tryAdvanceQueueStatus(queueDefinition.getQueueName(), QueueStatus.Active);
        }

        return false;
    }

    private boolean tryUpsertQueueDefinition(@NonNull final QueueDefinition queueDefinition) {
        final Logger upsertLogger = logger.with("queue-name", queueDefinition.getQueueName());

        queueDefinition.setAccountName(accountName);

        if (insertQueueIfNotExist(queueDefinition)) {
            upsertLogger.success("Created new queue");

            return true;
        }

        final Optional<QueueDefinition> currentQueueDefinitionOption = getQueueUnsafe(queueDefinition.getQueueName());

        if (!currentQueueDefinitionOption.isPresent()) {
            // a queue was deleted in the time that it was inactive + already existing
            // try creating the queue again since that meant that the deletion job
            // succeeded just after the insert statement attempted.

            upsertLogger.warn("The queue was deleted after being in an inactive state");

            return tryUpsertQueueDefinition(queueDefinition);
        }

        final QueueDefinition currentQueueDefinition = currentQueueDefinitionOption.get();

        // not provisionable state
        if (currentQueueDefinition.getStatus().ordinal() < QueueStatus.Deleting.ordinal()) {
            upsertLogger.with("current-status", currentQueueDefinition.getStatus())
                        .with("version", currentQueueDefinition.getVersion())
                        .warn("Queue status does not allow provisioning");

            return false;
        }

        // make sure the pointers exist
        ensurePointers(currentQueueDefinition);

        return tryUpdateQueueDefinition(queueDefinition);
    }

    private boolean tryUpdateQueueDefinition(final QueueDefinition currentQueueDefinition) {
        final Logger upsertLogger = logger.with("queue-name", currentQueueDefinition.getQueueName());

        // if however, an inactve queue exists, make sure only one person can
        // create the next active queue (prevent race conditions of multiple queues being created
        // of the same name that are active
        // first check the table name version table

        final int currentVersion = currentQueueDefinition.getVersion();

        final QueueDefinition nextQueueDefinition = currentQueueDefinition.toBuilder().version(currentVersion + 1).build();

        final QueueStatsId newQueueCounterId = getUniqueQueueCounterId(nextQueueDefinition.getId());

        // update the tracking table to see who can grab the next version
        // only if the queue name status is inactive
        // if its available grab it
        final Statement updateQueueDefinitionStatement =
                QueryBuilder.update(Tables.Queue.TABLE_NAME)
                            .where(eq(Tables.Queue.QUEUE_NAME, nextQueueDefinition.getQueueName().get()))
                            .and(eq(Tables.Queue.ACCOUNT_NAME, accountName.get()))
                            .with(set(Tables.Queue.VERSION, nextQueueDefinition.getVersion()))
                            .and(set(Tables.Queue.STATUS, QueueStatus.Active.ordinal()))
                            .and(set(Tables.Queue.BUCKET_SIZE, nextQueueDefinition.getBucketSize().get()))
                            .and(set(Tables.Queue.MAX_DELIVERY_COUNT, nextQueueDefinition.getMaxDeliveryCount()))
                            .and(set(Tables.Queue.QUEUE_STATS_ID, newQueueCounterId.get()))
                            .and(set(Tables.Queue.DLQ_NAME, nextQueueDefinition.getDlqName().map(QueueName::get).orElse(null)))
                            .and(set(Tables.Queue.STRICT_FIFO, nextQueueDefinition.isStrictFifo()))
                            .onlyIf(eq(Tables.Queue.VERSION, currentVersion))
                            .and(gte(Tables.Queue.STATUS, QueueStatus.Deleting.ordinal()));

        final boolean queueUpdateApplied = session.execute(updateQueueDefinitionStatement).wasApplied();

        if (queueUpdateApplied) {
            upsertLogger.success("Update queue to active");
        }

        return queueUpdateApplied;
    }

    private QueueStatsId getUniqueQueueCounterId(final QueueId queueId) {
        return QueueStatsId.valueOf(String.format("%s_%s", UUID.randomUUID(), queueId));
    }

    private void ensurePointers(final QueueDefinition queueDefinition) {
        insertQueueMonotonicValueIfNotExists(queueDefinition.getId());

        insertQueuePointerIfNotExists(queueDefinition.getId());
    }

    private void insertQueuePointerIfNotExists(@NonNull final QueueId queueId) {
        initializePointer(queueId, PointerType.BUCKET_POINTER, 0L);
        initializePointer(queueId, PointerType.INVISIBILITY_POINTER, -1L);
        initializePointer(queueId, PointerType.REPAIR_BUCKET, 0L);
    }

    private void insertQueueMonotonicValueIfNotExists(@NonNull final QueueId queueId) {
        final Statement insert = QueryBuilder.insertInto(Tables.Monoton.TABLE_NAME)
                                             .ifNotExists()
                                             .value(Tables.Monoton.VALUE, 0L)
                                             .value(Tables.Monoton.QUEUE_ID, queueId.get());

        session.execute(insert);
    }

    private void initializePointer(@NonNull final QueueId queueId, @NonNull final PointerType pointerType, @NonNull final Long value) {
        final Statement upsert = QueryBuilder.insertInto(Tables.Pointer.TABLE_NAME)
                                             .ifNotExists()
                                             .value(Tables.Pointer.VALUE, value)
                                             .value(Tables.Pointer.POINTER_TYPE, pointerType.toString())
                                             .value(Tables.Pointer.QUEUE_ID, queueId.get());

        session.execute(upsert);
    }

    /**
     * Returns the queue regardless of status
     *
     * @param queueName
     * @return
     */
    @Override
    public Optional<QueueDefinition> getQueueUnsafe(@NonNull final QueueName queueName) {
        final Select.Where queryOne =
                QueryBuilder.select().all()
                            .from(Tables.Queue.TABLE_NAME)
                            .where(eq(Tables.Queue.QUEUE_NAME, queueName.get()))
                            .and(eq(Tables.Queue.ACCOUNT_NAME, accountName.get()));

        final QueueDefinition result = getOne(session.execute(queryOne), QueueDefinition::fromRow);

        return Optional.ofNullable(result);
    }


    @Override
    public List<QueueDefinition> getQueues(QueueStatus status) {
        final Select query = QueryBuilder.select().all().from(Tables.Queue.TABLE_NAME);

        return session.execute(query)
                      .all()
                      .stream()
                      .map(QueueDefinition::fromRow)
                      .filter(queue -> queue.getStatus().equals(status))
                      .collect(toList());
    }

    @Override
    public Optional<QueueDefinition> getActiveQueue(final QueueName name) {
        final Optional<QueueDefinition> queue = getQueueUnsafe(name);

        return queue.filter(queueDef -> queueDef.getStatus() == QueueStatus.Active);
    }

    @Override
    public void deleteCompletionJob(final DeletionJob job) {
        final Statement delete = QueryBuilder.delete()
                                             .from(Tables.DeletionJob.TABLE_NAME)
                                             .where(eq(Tables.DeletionJob.QUEUE_NAME, job.getQueueName().get()))
                                             .and(eq(Tables.Queue.ACCOUNT_NAME, accountName.get()))
                                             .and(eq(Tables.DeletionJob.VERSION, job.getVersion()));

        if (session.execute(delete).wasApplied()) {
            logger.with(job).success("Removed deletion job");

            if (deleteIfInActive(job.getQueueName())) {
                logger.with("queue-name", job.getQueueName())
                      .with("version", job.getVersion())
                      .info("Deleted queue definition since it was inactive");
            }
        }
    }

    @Override
    public void deleteQueueStats(QueueStatsId counterId) {
        final Statement delete = QueryBuilder.delete()
                                             .from(Tables.QueueStats.TABLE_NAME)
                                             .where(eq(Tables.QueueStats.QUEUE_STATS_ID, counterId.get()));

        if (session.execute(delete).wasApplied()) {
            logger.with("counter_id", counterId)
                  .success("Deleted queue stats");
        }
    }


    private boolean deleteIfInActive(QueueName queueName) {
        final Statement delete = QueryBuilder.delete()
                                             .from(Tables.Queue.TABLE_NAME)
                                             .onlyIf(eq(Tables.Queue.STATUS, QueueStatus.Inactive.ordinal()))
                                             .where(eq(Tables.Queue.ACCOUNT_NAME, accountName.get()))
                                             .and(eq(Tables.Queue.QUEUE_NAME, queueName.get()));

        return session.execute(delete).wasApplied();
    }
}
