/*
 * Copyright 2014-2017 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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.hawkular.metrics.sysconfig;

import static com.datastax.driver.core.BatchStatement.Type.UNLOGGED;

import org.hawkular.rx.cassandra.driver.RxSession;

import com.datastax.driver.core.BatchStatement;
import com.datastax.driver.core.ConsistencyLevel;
import com.datastax.driver.core.PreparedStatement;

import rx.Completable;
import rx.Observable;
import rx.Scheduler;

/**
 * @author jsanda
 */
public class ConfigurationService {

    private RxSession session;

    private PreparedStatement findConfigurationGroup;

    private PreparedStatement findConfigurationValue;

    private PreparedStatement updateConfigurationValue;

    private PreparedStatement deleteConfigurationValue;

    private PreparedStatement deleteConfiguration;

    // TODO make async
    // I could have just as easily passed the session as a constructor arg. I am doing it in the init method because
    // eventually I would like service initialization async.
    public void init(RxSession session) {
        this.session = session;
        findConfigurationGroup = session.getSession()
                .prepare("SELECT name, value FROM sys_config WHERE config_id = ?")
                .setConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM);

        findConfigurationValue = session.getSession()
                .prepare("SELECT value FROM sys_config WHERE config_id = ? AND name= ?")
                .setConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM);

        updateConfigurationValue = session.getSession().prepare(
                "INSERT INTO sys_config (config_id, name, value) VALUES (?, ?, ?)")
                .setConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM);

        deleteConfigurationValue = session.getSession().prepare(
                "DELETE FROM sys_config WHERE config_id =? and name = ?")
                .setConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM);

        deleteConfiguration = session.getSession().prepare(
                "DELETE FROM sys_config WHERE config_id = ?")
                .setConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM);
    }

    public Observable<Configuration> load(String id) {
        return session.executeAndFetch(findConfigurationGroup.bind(id))
                .toMap(row -> row.getString(0), row -> row.getString(1))
                .map(map -> new Configuration(id, map));
    }

    public Observable<String> load(String id, String name) {
        return session.executeAndFetch(findConfigurationValue.bind(id, name))
                .map(row -> row.getString(0));
    }

    public Observable<Configuration> load(String id, Scheduler scheduler) {
        return session.executeAndFetch(updateConfigurationValue.bind(id), scheduler)
                .toMap(row -> row.getString(0), row -> row.getString(1))
                .map(map -> new Configuration(id, map));
    }

    public Observable<Void> save(Configuration configuration) {
        return Observable.from(configuration.getProperties().entrySet())
                .map(entry -> updateConfigurationValue.bind(configuration.getId(), entry.getKey(), entry.getValue()))
                .collect(() -> new BatchStatement(UNLOGGED), BatchStatement::add)
                .flatMap(batch -> session.execute(batch).map(resultSet -> null));
    }

    public Observable<Void> save(String configId, String name, String value) {
        return session.execute(updateConfigurationValue.bind(configId, name, value))
                .map(resultSet -> null);
    }

    public Observable<Void> save(String configId, String name, String value, Scheduler scheduler) {
        return session.execute(updateConfigurationValue.bind(configId, name, value), scheduler).map(resultSet -> null);
    }

    public Completable delete(String configId, String name) {
        return session.execute(deleteConfigurationValue.bind(configId, name)).toCompletable();
    }

    public Completable delete(String configId) {
        return session.execute(deleteConfiguration.bind(configId)).toCompletable();
    }
}


/*
 * Copyright 2014-2018 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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.hawkular.metrics.scheduler.impl;

import static java.util.stream.Collectors.toSet;

import static org.hawkular.metrics.datetime.DateTimeService.currentMinute;
import static org.hawkular.metrics.datetime.DateTimeService.getTimeSlice;
import static org.hawkular.metrics.datetime.DateTimeService.now;
import static org.joda.time.Minutes.minutes;

import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.NavigableSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;

import org.hawkular.metrics.scheduler.api.JobDetails;
import org.hawkular.metrics.scheduler.api.JobStatus;
import org.hawkular.metrics.scheduler.api.RepeatingTrigger;
import org.hawkular.metrics.scheduler.api.Scheduler;
import org.hawkular.metrics.scheduler.api.Trigger;
import org.hawkular.rx.cassandra.driver.RxSession;
import org.jboss.logging.Logger;
import org.joda.time.DateTime;

import com.datastax.driver.core.ConsistencyLevel;
import com.datastax.driver.core.PreparedStatement;
import com.google.common.base.Stopwatch;
import com.google.common.util.concurrent.ThreadFactoryBuilder;

import rx.Completable;
import rx.Observable;
import rx.Single;
import rx.functions.Action0;
import rx.functions.Func1;
import rx.schedulers.Schedulers;
import rx.subjects.PublishSubject;

/**
 * @author jsanda
 */
public class SchedulerImpl implements Scheduler {

    private static Logger logger = Logger.getLogger(SchedulerImpl.class);

    private Map<String, Func1<JobDetails, Completable>> jobFactories;

    private ScheduledExecutorService tickExecutor;

    private rx.Scheduler tickScheduler;

    private ExecutorService queryExecutor;

    private rx.Scheduler queryScheduler;

    private RxSession session;

    private PreparedStatement deleteScheduledJobs;

    private PreparedStatement findFinishedJobs;

    private PreparedStatement deleteFinishedJobs;

    private PreparedStatement updateJobToFinished;

    private PreparedStatement addActiveTimeSlice;

    private PreparedStatement findActiveTimeSlices;

    private PreparedStatement deleteActiveTimeSlice;

    private LockManager lockManager;

    private JobsService jobsService;

    private boolean running;

    static final String QUEUE_LOCK_PREFIX = "org.hawkular.metrics.scheduler.queue.";

    static final String SCHEDULING_LOCK = "scheduling";

    static final String TIME_SLICE_EXECUTION_LOCK = "executing";

    // TODO We probably want this to be configurable
    static final int SCHEDULING_LOCK_TIMEOUT_IN_SEC = 5;

    // TODO We probably want this to be configurable
    static final int JOB_EXECUTION_LOCK_TIMEOUT_IN_SEC = 1800;

    /**
     * Test hook. See {@link #setTimeSlicesSubject(PublishSubject)}.
     */
    private Optional<PublishSubject<Date>> finishedTimeSlices;

    private Optional<PublishSubject<JobDetails>> jobFinished;

    private final Object lock = new Object();

    private String hostname;

    public SchedulerImpl(RxSession session, String hostname) {
        this(session, hostname, new JobsService(session));
    }

    public SchedulerImpl(RxSession session, String hostname, JobsService jobsService) {
        this.session = session;
        jobFactories = new HashMap<>();

        tickExecutor = new ScheduledThreadPoolExecutor(1, new ThreadFactoryBuilder()
                .setNameFormat("ticker-pool-%d").build(), new ThreadPoolExecutor.DiscardPolicy());
        tickScheduler = Schedulers.from(tickExecutor);

        ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("query-thread-pool-%d").build();
        queryExecutor = new ThreadPoolExecutor(getQueryThreadPoolSize(), getQueryThreadPoolSize(), 0,
                TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(), threadFactory,
                new ThreadPoolExecutor.DiscardPolicy());
        queryScheduler = Schedulers.from(queryExecutor);

        lockManager = new LockManager(session);
        this.jobsService = jobsService;

        deleteScheduledJobs = initQuery("DELETE FROM scheduled_jobs_idx WHERE time_slice = ?");
        findFinishedJobs = initQuery("SELECT job_id FROM finished_jobs_idx WHERE time_slice = ?");
        deleteFinishedJobs = initQuery("DELETE FROM finished_jobs_idx WHERE time_slice = ?");
        updateJobToFinished = initQuery("INSERT INTO finished_jobs_idx (time_slice, job_id) VALUES (?, ?)");
        addActiveTimeSlice = initQuery("INSERT INTO active_time_slices (time_slice) VALUES (?)");
        findActiveTimeSlices = initQuery("SELECT DISTINCT time_slice FROM active_time_slices");
        deleteActiveTimeSlice = initQuery("DELETE FROM active_time_slices WHERE time_slice = ?");

        finishedTimeSlices = Optional.empty();
        jobFinished = Optional.empty();

        this.hostname = hostname;
    }

    private PreparedStatement initQuery(String cql) {
        return session.getSession().prepare(cql).setConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM);
    }

    // TODO make configurable
    private int getQueryThreadPoolSize() {
        return Math.max(Runtime.getRuntime().availableProcessors() / 2, 1);
    }

    /**
     * Test hook to allow control of when ticks are emitted.
     */
    public void setTickScheduler(rx.Scheduler scheduler) {
        tickScheduler = scheduler;
    }

    /**
     * Test hook to broadcast when the job scheduler has finished all work for a time slice. This includes not only
     * executing jobs that are scheduled for a particular time slice but also post-job execution clean up.
     */
    public void setTimeSlicesSubject(PublishSubject<Date> timeSlicesSubject) {
        finishedTimeSlices = Optional.of(timeSlicesSubject);
    }

    /**
     * Test hook to broadcast when jobs finish executing.
     */
    public void setJobFinishedSubject(PublishSubject<JobDetails> subject) {
        jobFinished = Optional.of(subject);
    }

    @Override
    public void register(String jobType, Func1<JobDetails, Completable> jobProducer) {
        jobFactories.put(jobType, jobProducer);
    }

    @Override
    public Single<? extends JobDetails> scheduleJob(String type, String name, Map<String, String> parameters,
            Trigger trigger) {
        if (now.get().getMillis() >= trigger.getTriggerTime()) {
            return Single.error(new RuntimeException("Trigger time has already passed"));
        }
        String lockName = QUEUE_LOCK_PREFIX + trigger.getTriggerTime();
        // The lock we obtain here is basically a shared lock. We need a lock to avoid a possible race condition.
        // Without the lock the job could could be written to scheduled_jobs_idx and wind up having the partition to
        // which it was written deleted before the job is ever executed. The job would then never get executed.
        return lockManager.acquireLock(lockName, SCHEDULING_LOCK, SCHEDULING_LOCK_TIMEOUT_IN_SEC, false)
                .map(lock -> {
                    if (lock.isLocked()) {
                        UUID jobId = UUID.randomUUID();
                        return jobsService.createJobDetails(jobId, type, name, parameters, trigger,
                                new Date(trigger.getTriggerTime()));
                    }
                    throw new RuntimeException("Failed to acquire scheduling lock [" + lockName + "]");
                })
                .flatMap(details -> jobsService.insert(new Date(trigger.getTriggerTime()), details)
                        .map(resultSet -> details))
                .toSingle()
                .doOnError(t -> logger.warn("Failed to schedule job " + name, t));
    }

    @Override
    public Completable unscheduleJobById(String jobId) {
        return jobsService.deleteJob(UUID.fromString(jobId), queryScheduler);
    }

    @Override public Completable unscheduleJobByTypeAndName(String jobType, String jobName) {
        return Completable.merge(
                jobsService.findAllScheduledJobs(queryScheduler)
                .filter(details -> details.getJobType().equals(jobType) && details.getJobName().equals(jobName))
                .map(details -> jobsService.deleteJob(details.getJobId(), queryScheduler))
        );
    }

    @Override
    public void start() {
        running = true;
        NavigableSet<Date> activeTimeSlices = new ConcurrentSkipListSet<>();
        Set<UUID> activeJobs = new ConcurrentSkipListSet<>();

        doOnTick(() -> {
            logger.debugf("Activating scheduler for [%s]", currentMinute().toDate());
            Observable.just(currentMinute().toDate())
                    .concatMap(time -> jobsService.findActiveTimeSlices(time, queryScheduler))
                    .filter(d -> {
                        synchronized (lock) {
                            if (!activeTimeSlices.contains(d)) {
                                activeTimeSlices.add(d);
                                return true;
                            }
                            return false;
                        }
                    })
                    .concatMap(this::acquireTimeSliceLock)
                    .concatMap(timeSliceLock -> findScheduledJobs(timeSliceLock.getTimeSlice())
                            .doOnError(t -> {
                                logger.warnf("Failed to find schedule jobs for time slice %s", timeSliceLock
                                        .timeSlice);
                            })
                            .doOnNext(jobs -> logger.debugf("[%s] scheduled jobs: %s", timeSliceLock.timeSlice, jobs))
                            .flatMap(scheduledJobs -> computeRemainingJobs(scheduledJobs, timeSliceLock.getTimeSlice(),
                                    activeJobs))
                            .doOnNext(jobs -> logger.debugf("[%s] remaining jobs: %s", timeSliceLock.timeSlice, jobs))
                            .flatMap(Observable::from)
                            .filter(jobDetails -> !activeJobs.contains(jobDetails.getJobId()))
                            .flatMap(this::acquireJobLock)
                            .filter(jobLock -> jobLock.acquired)
                            .map(jobLock -> jobLock.jobDetails)
                            .doOnNext(details -> {
                                logger.debugf("Acquired job lock for %s in time slice %s", details,
                                        timeSliceLock.timeSlice);
                                activeJobs.add(details.getJobId());
                            })
                            // The following observeOn call is critical as it causes execution of the job Completable
                            // subscription to execute on the I/O scheduler. We do not want subscription to run on
                            // either the computation or the queryScheduler. See HWKMETRICS-579 for more details.
                            .observeOn(Schedulers.io())
                            .flatMap(details -> executeJob(details, timeSliceLock.timeSlice, activeJobs)
                                    .doOnTerminate(() -> activeJobs.remove(details.getJobId()))
                                    .toObservable()
                                    .map(o -> timeSliceLock.getTimeSlice()))
                            .defaultIfEmpty(timeSliceLock.getTimeSlice()))
                    // Resume execution back on the queryScheduler (and the computation scheduler) since everything
                    // else that happens from this point on is either non-block or very short lived computations.
                    .observeOn(queryScheduler)
                    .concatMap(time -> {
                        Observable<? extends Set<UUID>> scheduled = jobsService.findScheduledJobsForTime(time,
                                queryScheduler)
                                .map(JobDetails::getJobId)
                                .collect(HashSet<UUID>::new, HashSet::add);
                        Observable<? extends Set<UUID>> finished = findFinishedJobs(time);
                        return Observable.sequenceEqual(scheduled, finished).flatMap(allFinished -> {
                            if (allFinished) {
                                logger.debugf("All jobs for time slice [%s] have finished", time);
                                return deleteFinishedJobs(time).mergeWith(deleteScheduledJobs(time))
                                        // Without the reduce call here, the completable does not get executed.
                                        // Not sure why.
                                        .toObservable()
                                        .reduce(null, (o1, o2) -> o2)
                                        .map(o -> time);
                            }
                            return Observable.just(time);
                        });
                    })
                    .subscribe(
                            d -> {
                                logger.debugf("Finished post job execution clean up for [%s]", d);
                                activeTimeSlices.remove(d);
                                finishedTimeSlices.ifPresent(subject -> subject.onNext(d));
                            },
                            t -> {
                                logger.warn("Job execution failed", t);
                                synchronized (lock) {
                                    // When there is an error we need to make sure the corresponding time slice is
                                    // removed from activeTimeSlices. See HWKMETRICS-518 for more details.
                                    //
                                    // Since wee do not have the timestamp here we simply remove everything. This will
                                    // cause any duplicate job execution since each job has a lock associated with it.
                                    // Clearing this cache may result in extra attempts at acquiring job locks. I plan
                                    // change this. See HWKMETRICS-522.
                                    activeTimeSlices.clear();
                                }
                            },
                            () ->  logger.debug("Done!")
                    );
        });
    }

    private Observable<TimeSliceLock> acquireTimeSliceLock(Date timeSlice) {
        String lockName = QUEUE_LOCK_PREFIX + timeSlice.getTime();
        int delay = 5;
        Observable<TimeSliceLock> observable = Observable.create(subscriber -> {
            // This is a shared lock and it is mutually exclusive with the one that is acquired for scheduling a job.
            lockManager.acquireLock(lockName, TIME_SLICE_EXECUTION_LOCK, JOB_EXECUTION_LOCK_TIMEOUT_IN_SEC, false)
                    .map(lock -> {
                        if (!lock.isLocked()) {
                            logger.debugf("Failed to acquire time slice lock for [%s]. Will attempt to acquire it " +
                                    "again in %d seconds", timeSlice, delay);
                            throw new RuntimeException();
                        }
                        return new TimeSliceLock(timeSlice, lockName, lock.isLocked());
                    })
                    .subscribe(subscriber::onNext, subscriber::onError, subscriber::onCompleted);
        });
        // We keep retrying until we acquire the lock. If the lock is held for scheduling, it will expire in 5 seconds
        // and we can obtain it for executing jobs. We should eventually acquire the lock because once the system
        // clock catches up to the time slice, we won't allow any more clients to obtain the lock for scheduling. If
        // system clocks are screwed up, we could have a problem.
        return observable.retryWhen(errors -> errors.flatMap(e -> Observable.timer(delay, TimeUnit.SECONDS,
                queryScheduler)));
    }

    private Observable<JobLock> acquireJobLock(JobDetailsImpl jobDetails) {
        String jobLock = "org.hawkular.metrics.scheduler.job." + jobDetails.getJobId();
        int timeout = calculateTimeout(jobDetails.getTrigger());
        return lockManager.acquireLock(jobLock, hostname, timeout, true)
                .map(lock -> new JobLock(jobDetails, lock))
                .doOnNext(lock -> logger.debugf("Acquired lock for %s? %s", jobDetails.getJobName(),
                        lock.acquired));
    }

    private int calculateTimeout(Trigger trigger) {
        if (trigger instanceof RepeatingTrigger) {
            int interval = (int) (((RepeatingTrigger) trigger).getInterval() / 1000);
            return (int) (interval + (interval * 0.25));
        }
        return JOB_EXECUTION_LOCK_TIMEOUT_IN_SEC;
    }

    private Completable executeJob(JobDetailsImpl details, Date timeSlice, Set<UUID> activeJobs) {
        logger.debugf("Starting execution for %s in time slice [%s]", details, timeSlice);
        Stopwatch stopwatch = Stopwatch.createStarted();
        Func1<JobDetails, Completable> factory = jobFactories.get(details.getJobType());
        Completable job;

        if (factory == null) {
            job = Completable.error(new UnregisteredJobException(details, timeSlice));
        } else if (details.getStatus() == JobStatus.FINISHED) {
            Observable<Completable> observable = jobsService.findScheduledExecutions(details.getJobId(), queryScheduler)
                    .filter(scheduledExecution -> scheduledExecution.getJobDetails().getStatus() == JobStatus.NONE &&
                            scheduledExecution.getJobDetails().getTrigger().getTriggerTime() >
                                    details.getTrigger().getTriggerTime())
                    .isEmpty()
                    .map(isEmpty -> {
                        if (isEmpty) {
                            return doPostJobExecution(Completable.complete(), details, timeSlice, activeJobs);
                        }
                        return doPostJobExecutionWithoutRescheduling(Completable.complete(), details, timeSlice,
                                activeJobs);
                    });
            job = Completable.merge(observable);
        } else {
            jobsService.prepareJobDetailsForExecution(details, timeSlice);
            job = factory
                    .call(details)
                    .doOnTerminate(() -> jobsService.resetJobDetails(details))
                    .doOnCompleted(() -> {
                        stopwatch.stop();
                        if (logger.isDebugEnabled()) {
                            logger.debugf("Finished executing %s in time slice [%s] in %s ms", details, timeSlice,
                                    stopwatch.elapsed(TimeUnit.MILLISECONDS));
                        }
            });
            job = doPostJobExecution(job, details, timeSlice, activeJobs);
        }

        return job.subscribeOn(Schedulers.io());
    }

    private Completable doPostJobExecution(Completable job, JobDetailsImpl jobDetails, Date timeSlice,
            Set<UUID> activeJobs) {

        return job
                .toSingle(() -> new JobExecutionState(jobDetails, timeSlice, null, null, activeJobs))
                .onErrorReturn(t -> {
                    logger.warnf(t, "Job execution of %s for time slice %d failed", jobDetails, timeSlice.getTime());
                    return new JobExecutionState(jobDetails, timeSlice, t, activeJobs);
                })
                .flatMap(state -> {
                    // Only set the job status to finished if it completed normally without error. If there was an
                    // error, the job will get executed again for the current time slice.
                    if (state.error == null) {
                        return jobsService.updateStatusToFinished(timeSlice, state.currentDetails.getJobId())
                                .toSingle()
                                .map(resultSet -> state);
                    } else {
                        return Single.just(state);
                    }

                })
                .flatMap(state -> {
                    // Only reschedule if the job completed normally without error. In other words, advance the trigger
                    // time only when the job completes normally.
                    if (state.error == null) {
                        return reschedule(state);
                    } else {
                        return Single.just(state);
                    }
                })
                .flatMap(state -> {
                    // We only want to release the job execution lock if the job completed normally. Acquiring and
                    // releasing locks has overhead that can be avoided when we are going to retry the job again.
                    if (state.error == null) {
                        return releaseJobExecutionLock(state).flatMap(this::setJobFinished);
                    } else {
                        return Single.just(state);
                    }
                })
                .doOnError(t -> {
                    logger.warn("There was an error during post-job execution", t);
                    publishJobFinished(jobDetails);
                })
                .doAfterTerminate(() -> publishJobFinished(jobDetails))
                .toCompletable();
    }

    private Completable doPostJobExecutionWithoutRescheduling(Completable job, JobDetailsImpl jobDetails,
            Date timeSlice, Set<UUID> activeJobs) {
        return job
                .toSingle(() -> new JobExecutionState(jobDetails, activeJobs))
                .flatMap(this::releaseJobExecutionLock)
                .flatMap(this::setJobFinished)
                .doOnError(t -> {
                    logger.debug("There was an error during post-job execution, but the job has already been " +
                            "rescheduled.", t);
                    publishJobFinished(jobDetails);
                })
                .doOnSuccess(states -> publishJobFinished(states.currentDetails))
                .toCompletable();
    }

    private void publishJobFinished(JobDetails details) {
        jobFinished.ifPresent(subject -> subject.onNext(details));
    }

    private Single<JobExecutionState> setJobFinished(JobExecutionState state) {
        return session.execute(updateJobToFinished.bind(state.timeSlice,
                state.currentDetails.getJobId()), queryScheduler)
                .toSingle()
                .map(resultSet -> state)
                .doOnError(t -> logger.warnf(t, "There was an error while updated the finished jobs index for %s",
                        state.currentDetails));
    }

    /**
     * If a job is single execution then this is a no-op; otherwise, the scheduled jobs index and jobs tables are
     * updated with the next execution time/details. If the job has missed its next execution, it will get scheduled
     * in the next available time slice. The job scheduler will actually execute the job immediately when the job falls
     * behind, but we still want to persist the scheduling update for durability.
     */
    private Single<JobExecutionState> reschedule(JobExecutionState executionState) {
        Trigger nextTrigger = executionState.currentDetails.getTrigger().nextTrigger();
        if (nextTrigger == null) {
            logger.debugf("No more scheduled executions for %s", executionState.currentDetails);
            return Single.just(executionState);
        }

        JobDetailsImpl details = executionState.currentDetails;
        JobDetailsImpl newDetails = new JobDetailsImpl(details, nextTrigger);

        if (nextTrigger.getTriggerTime() <= now.get().getMillis()) {
            logger.infof("%s missed its next execution at %d. It will be rescheduled for immediate execution.",
                    details, nextTrigger.getTriggerTime());
            AtomicLong nextTimeSlice = new AtomicLong(currentMinute().getMillis());
            Observable<Lock> scheduled = Observable.defer(() ->
                    lockManager.acquireLock(QUEUE_LOCK_PREFIX + nextTimeSlice.addAndGet(60000L), SCHEDULING_LOCK,
                            SCHEDULING_LOCK_TIMEOUT_IN_SEC, false))
                    .map(lock -> {
                        if (!lock.isLocked()) {
                            throw new RuntimeException();
                        }
                        return lock;
                    })
                    .retry();
            return scheduled
                    .map(lock -> new JobExecutionState(executionState.currentDetails, executionState.timeSlice,
                            newDetails, new Date(nextTimeSlice.get()), executionState.activeJobs))
                    .flatMap(state -> jobsService.insert(state.nextTimeSlice, state.nextDetails)
                            .map(updated -> state))
                    .doOnNext(state -> logger.debugf("Rescheduled %s to execute in time slice %s with trigger time " +
                            "of %s", state.nextDetails.getJobName(), state.nextTimeSlice, new Date(state.nextDetails
                            .getTrigger().getTriggerTime())))
                    .toSingle();
        }

        logger.debugf("Scheduling %s for next execution at %s", newDetails, new Date(nextTrigger.getTriggerTime()));

        JobExecutionState newState = new JobExecutionState(details, executionState.timeSlice, newDetails,
                new Date(nextTrigger.getTriggerTime()), executionState.activeJobs);
        return jobsService.insert(newState.nextTimeSlice, newState.nextDetails)
                .map(updated -> newState)
                .toSingle();
    }

    private Single<JobExecutionState> releaseJobExecutionLock(JobExecutionState state) {
        String jobLock = "org.hawkular.metrics.scheduler.job." + state.currentDetails.getJobId();
        return lockManager.releaseLock(jobLock, hostname)
                .map(released -> {
                    if (!released) {
                        logger.warnf("Failed to release job lock for %s", state.currentDetails);
                    }
                    return state;
                })
                .toSingle()
                .doOnError(t -> logger.warnf(t, "There was an error trying to release job lock [%s] for %s", jobLock,
                        state.currentDetails));
    }

    private Completable deleteScheduledJobs(Date timeSlice) {
        return session.execute(deleteScheduledJobs.bind(timeSlice), queryScheduler)
                .doOnCompleted(() -> logger.debugf("Deleted scheduled jobs time slice [%s]", timeSlice))
                .toCompletable();
    }

    private Completable deleteFinishedJobs(Date timeSlice) {
        return session.execute(deleteFinishedJobs.bind(timeSlice), queryScheduler)
                .doOnCompleted((() -> logger.debugf("Deleted finished jobs time slice [%s]", timeSlice)))
                .toCompletable();
    }

    private Completable deleteActiveTimeSlice(Date timeSlice) {
        return session.execute(deleteActiveTimeSlice.bind(timeSlice), queryScheduler)
                .toCompletable();
    }

    @Override
    public void shutdown() {
        try {
            running = false;
            tickExecutor.shutdown();
            tickExecutor.awaitTermination(5, TimeUnit.SECONDS);
            queryExecutor.shutdown();
            queryExecutor.awaitTermination(30, TimeUnit.SECONDS);
            lockManager.shutdown();
            logger.info("Shutdown complete");
        } catch (InterruptedException e) {
            logger.warn("Interrupted during shutdown", e);
        }
    }

    private Observable<? extends Set<JobDetailsImpl>> findScheduledJobs(Date timeSlice) {
        logger.debugf("Fetching scheduled jobs for [%s]", timeSlice);
        return jobsService.findScheduledJobsForTime(timeSlice, queryScheduler)
                .collect(HashSet<JobDetailsImpl>::new, HashSet::add);
    }

    private Observable<Set<JobDetailsImpl>> computeRemainingJobs(Set<JobDetailsImpl> scheduledJobs, Date timeSlice,
            Set<UUID> activeJobs) {
        Observable<? extends Set<UUID>> finished = findFinishedJobs(timeSlice);
        return finished.map(finishedJobs -> {
            Set<UUID> active = new HashSet<>(activeJobs);
            active.removeAll(finishedJobs);
            Set<JobDetailsImpl> jobs = scheduledJobs.stream()
                    .filter(details -> !finishedJobs.contains(details.getJobId()))
                    .collect(toSet());
            return jobs.stream().filter(details -> !active.contains(details.getJobId())).collect(toSet());
        });
    }

    private Observable<? extends Set<UUID>> findFinishedJobs(Date timeSlice) {
        return session.execute(findFinishedJobs.bind(timeSlice), queryScheduler)
                .flatMap(Observable::from)
                .map(row -> row.getUUID(0))
                .collect(HashSet<UUID>::new, HashSet::add);
    }

    private void doOnTick(Action0 action) {
        Action0 wrapper = () -> {
            Date timeSlice = getTimeSlice(new DateTime(tickScheduler.now()), minutes(1).toStandardDuration()).toDate();
            action.call();
        };
        AtomicReference<DateTime> previousTimeSliceRef = new AtomicReference<>();
        // TODO Emit ticks at the start of every minute
        Observable.interval(0, 1, TimeUnit.MINUTES, tickScheduler)
                .filter(tick -> {
                    DateTime time = currentMinute();
                    if (previousTimeSliceRef.get() == null) {
                        previousTimeSliceRef.set(time);
                        return true;
                    }
                    if (previousTimeSliceRef.get().equals(time)) {
                        return false;
                    }
                    previousTimeSliceRef.set(time);
                    return true;
                })
                .takeUntil(d -> !running)
                .subscribe(tick -> wrapper.call(), t -> logger.warn(t));
    }

    private Observable<Void> updateActiveTimeSlices(Date timeSlice) {
        return session.execute(addActiveTimeSlice.bind(timeSlice)).map(resultSet -> null);
    }

    private Observable<Date> findTimeSlices() {
        return session.execute(findActiveTimeSlices.bind(), queryScheduler)
                .flatMap(Observable::from)
                .map(row -> row.getTimestamp(0))
                .toSortedList()
                .flatMap(Observable::from);
    }

    private static class TimeSliceLock {
        private Date timeSlice;
        private String name;
        private boolean acquired;

        public TimeSliceLock(Date timeSlice, String name, boolean acquired) {
            this.timeSlice = timeSlice;
            this.name = name;
            this.acquired = acquired;
        }

        public Date getTimeSlice() {
            return timeSlice;
        }

        public String getName() {
            return name;
        }

        public boolean isAcquired() {
            return acquired;
        }
    }

    private static class JobLock {
        final JobDetailsImpl jobDetails;
        final boolean acquired;
        final String name;

        public JobLock(JobDetailsImpl jobDetails, Lock lock) {
            this.jobDetails = jobDetails;
            this.acquired = lock.isLocked();
            this.name = "org.hawkular.metrics.scheduler.job." + jobDetails.getJobId();
        }
    }

    /**
     * Normally jobs are executed in the time slice specified by their triggers. If a job falls behind though, it
     * will be executed immediately
     */
    private static class JobExecutionState {
        final JobDetailsImpl currentDetails;
        final JobDetailsImpl nextDetails;
        final Set<UUID> activeJobs;
        final Date timeSlice;
        final Date nextTimeSlice;
        final Throwable error;

        public JobExecutionState(JobDetailsImpl details, Set<UUID> activeJobs) {
            this.currentDetails = details;
            this.activeJobs = activeJobs;
            this.timeSlice = new Date(details.getTrigger().getTriggerTime());
            nextDetails = null;
            nextTimeSlice = null;
            this.error = null;
        }

        public JobExecutionState(JobDetailsImpl details, Date timeSlice, Throwable error, Set<UUID> activeJobs) {
            this.currentDetails = details;
            this.activeJobs = activeJobs;
            this.timeSlice = timeSlice;
            nextDetails = null;
            nextTimeSlice = null;
            this.error = error;
        }

        public JobExecutionState(JobDetailsImpl details, Date timeSlice, JobDetailsImpl nextDetails, Date nextTimeSlice,
                Set<UUID> activeJobs) {
            this.currentDetails = details;
            this.timeSlice = timeSlice;
            this.nextDetails = nextDetails;
            this.nextTimeSlice = nextTimeSlice;
            this.activeJobs = activeJobs;
            this.error = null;
        }

        public boolean isRepeating() {
            return nextDetails != null && nextTimeSlice != null;
        }

        public boolean isBehindSchedule() {
            return isRepeating() && nextTimeSlice != null && nextTimeSlice.getTime() > nextDetails.getTrigger()
                    .getTriggerTime();
        }

    }

}


/*
 * Copyright 2014-2018 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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.hawkular.metrics.scheduler.impl;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.UUID;
import java.util.function.Function;

import org.hawkular.metrics.scheduler.api.JobDetails;
import org.hawkular.metrics.scheduler.api.JobStatus;
import org.hawkular.metrics.scheduler.api.RepeatingTrigger;
import org.hawkular.metrics.scheduler.api.SingleExecutionTrigger;
import org.hawkular.metrics.scheduler.api.Trigger;
import org.hawkular.rx.cassandra.driver.RxSession;
import org.jboss.logging.Logger;

import com.datastax.driver.core.KeyspaceMetadata;
import com.datastax.driver.core.PreparedStatement;
import com.datastax.driver.core.ResultSet;
import com.datastax.driver.core.Row;
import com.datastax.driver.core.UDTValue;
import com.datastax.driver.core.UserType;

import rx.Completable;
import rx.Observable;
import rx.functions.Func1;

/**
 * @author jsanda
 */
public class JobsService {

    private static Logger logger = Logger.getLogger(JobsService.class);

    private RxSession session;

    private PreparedStatement findTimeSlices;

    private PreparedStatement findScheduledForTime;

    private PreparedStatement findAllScheduled;

    private PreparedStatement insertScheduled;

    private PreparedStatement update;

    private PreparedStatement updateStatus;

    private PreparedStatement deleteScheduled;

    private PreparedStatement updateJobParameters;

    private PreparedStatement findByIdAndSlice;

    private static final Function<Map<String, String>, Completable> SAVE_PARAMS_NO_OP =
            params -> Completable.complete();

    // Fields that shouldn't be null: time_slice, job_id, job_type, job_name, trigger
    // Fields order: time_slice, job_id, job_type, job_name, job_params, trigger, status
    private Func1<Row, Boolean> filterNullJobs = row -> {
        boolean isNull = (row.isNull(0) || row.isNull(1)
                || row.isNull(2) || row.isNull(3) || row.isNull(5));
        if (isNull) {
            logger.warnf("Scheduled job with invalid values present in job_scheduled_idx" +
                            " [JobId: %s, TimeSlice: %s]", row.getTimestamp(0), row.getUUID(1));
        }
        return !isNull;
    };

    public JobsService(RxSession session) {
        this.session = session;
        findTimeSlices = session.getSession().prepare("SELECT DISTINCT time_slice FROM scheduled_jobs_idx");
        findScheduledForTime = session.getSession().prepare(
                "SELECT time_slice, job_id, job_type, job_name, job_params, trigger, status FROM scheduled_jobs_idx " +
                "WHERE time_slice = ?");

        // In general this is not a good way to execute queries in Cassandra; however, the number partitions with which
        // we are dealing is going to very small. The Cassandra clusters are also generally only two or three nodes.
        findAllScheduled = session.getSession().prepare(
                "SELECT time_slice, job_id, job_type, job_name, job_params, trigger, status FROM scheduled_jobs_idx");

        update = session.getSession().prepare(
                "INSERT INTO jobs (id, type, name, params, trigger) VALUES (?, ?, ?, ?, ?)");
        insertScheduled =  session.getSession().prepare(
                "INSERT INTO scheduled_jobs_idx (time_slice, job_id, job_type, job_name, job_params, trigger, " +
                "status) VALUES (?, ?, ?, ?, ?, ?, ?)");
        updateStatus = session.getSession().prepare(
                "UPDATE scheduled_jobs_idx SET status = ? WHERE time_slice = ? AND job_id = ?");
        deleteScheduled = session.getSession().prepare(
                "DELETE FROM scheduled_jobs_idx WHERE time_slice = ? AND job_id = ?");
        updateJobParameters = session.getSession().prepare(
                "UPDATE scheduled_jobs_idx SET job_params = ? WHERE time_slice = ? AND job_id = ?");
        findByIdAndSlice = session.getSession().prepare(
                "SELECT job_id FROM scheduled_jobs_idx WHERE time_slice = ? AND job_id = ?");
    }

    public Observable<Date> findActiveTimeSlices(Date currentTime, rx.Scheduler scheduler) {
        return session.executeAndFetch(findTimeSlices.bind(), scheduler)
                .map(row -> row.getTimestamp(0))
                .filter(timestamp -> timestamp.compareTo(currentTime) < 0)
                .toSortedList()
                .doOnNext(timeSlices -> logger.debugf("Active time slices %s", timeSlices))
                .flatMap(Observable::from)
                .concatWith(Observable.just(currentTime));
    }

    public Observable<JobDetailsImpl> findAllScheduledJobs(rx.Scheduler scheduler) {
        return session.executeAndFetch(findAllScheduled.bind(), scheduler)
                .filter(filterNullJobs)
                .map(row -> createJobDetails(
                        row.getUUID(1),
                        row.getString(2),
                        row.getString(3),
                        row.getMap(4, String.class, String.class),
                        getTrigger(row.getUDTValue(5)),
                        JobStatus.fromCode(row.getByte(6)),
                        row.getTimestamp(0)));
    }

    public Observable<JobDetailsImpl> findJobs(Date timeSlice, rx.Scheduler scheduler) {
        return findActiveTimeSlices(timeSlice, scheduler)
                .flatMap(time -> findScheduledJobsForTime(time, scheduler))
                .reduce(new HashMap<>(), (HashMap<UUID, JobDetailsImpl> map, JobDetailsImpl details) -> {
                    JobDetailsImpl other = map.get(details.getJobId());
                    if (other == null) {
                        map.put(details.getJobId(), details);
                    } else {
                        if (details.getTrigger().getTriggerTime() < other.getTrigger().getTriggerTime()) {
                            map.put(details.getJobId(), details);
                        }
                    }
                    return map;
                })
                .flatMap(map -> Observable.from(map.values()));
    }

    public Completable deleteJob(UUID jobId, rx.Scheduler scheduler) {
        return session.executeAndFetch(findTimeSlices.bind(), scheduler)
                .map(row -> row.getTimestamp(0))
                .flatMap(timeSlice -> session.execute(deleteScheduled.bind(timeSlice, jobId)))
                .toCompletable();
    }

    /**
     * This method is currently unused.
     */
    public Observable<JobDetails> findScheduledJobs(Date timeSlice, rx.Scheduler scheduler) {
        return session.executeAndFetch(findAllScheduled.bind(), scheduler)
                .filter(filterNullJobs)
                .filter(row -> row.getTimestamp(0).compareTo(timeSlice) <= 0)
                .map(row -> createJobDetails(
                        row.getUUID(1),
                        row.getString(2),
                        row.getString(3),
                        row.getMap(4, String.class, String.class),
                        getTrigger(row.getUDTValue(5)),
                        JobStatus.fromCode(row.getByte(6)),
                        timeSlice))
                .collect(HashMap::new, (Map<UUID, SortedSet<JobDetails>> map, JobDetails details) -> {
                    SortedSet<JobDetails> set = map.get(details.getJobId());
                    if (set == null) {
                        set = new TreeSet<>((JobDetails d1, JobDetails d2) ->
                                Long.compare(d1.getTrigger().getTriggerTime(), d2.getTrigger().getTriggerTime()));
                    }
                    set.add(details);
                    map.put(details.getJobId(), set);
                })
                .flatMap(map -> Observable.from(map.entrySet()))
                .map(entry -> entry.getValue().first());
    }

    public Observable<JobDetailsImpl> findScheduledJobsForTime(Date timeSlice, rx.Scheduler scheduler) {
        return session.executeAndFetch(findScheduledForTime.bind(timeSlice), scheduler)
                .filter(filterNullJobs)
                .map(row -> createJobDetails(
                        row.getUUID(1),
                        row.getString(2),
                        row.getString(3),
                        row.getMap(4, String.class, String.class),
                        getTrigger(row.getUDTValue(5)),
                        JobStatus.fromCode(row.getByte(6)),
                        timeSlice))
                .doOnSubscribe(() -> logger.debugf("Fetching scheduled jobs tor time slice [%s]", timeSlice))
                .doOnNext(details -> logger.debugf("Found job details %s", details));
    }

    public Observable<ScheduledExecution> findScheduledExecutions(UUID jobId, rx.Scheduler scheduler) {
        return session.executeAndFetch(findAllScheduled.bind(), scheduler)
                .filter(filterNullJobs)
                .filter(row -> row.getUUID(1).equals(jobId))
                .map(row -> new ScheduledExecution(row.getTimestamp(0), createJobDetails(
                        row.getUUID(1),
                        row.getString(2),
                        row.getString(3),
                        row.getMap(4, String.class, String.class),
                        getTrigger(row.getUDTValue(5)),
                        JobStatus.fromCode(row.getByte(6)),
                        row.getTimestamp(0))));
    }

    public Observable<ResultSet> insert(Date timeSlice, JobDetails job) {
        if (job.getJobId() == null || job.getJobName() == null || job.getJobType() == null || job.getTrigger() == null) {
            logger.warnf("Tried to insert job on scheduled jobs with invalid values, [JobId: %s, TimeSlice: %s]", job
                    .getJobId(), timeSlice);
        }
        return session.execute(insertScheduled.bind(timeSlice, job.getJobId(), job.getJobType(), job.getJobName(),
                job.getParameters().getMap(), getTriggerValue(session, job.getTrigger())));
    }

    public Observable<ResultSet> updateStatusToFinished(Date timeSlice, UUID jobId) {
        // First check if the job exists
        return session.executeAndFetch(findByIdAndSlice.bind(timeSlice, jobId))
                .flatMap(jobRow -> session.execute(updateStatus.bind((byte) 1, timeSlice, jobId))
                        .doOnError(t -> logger
                                .warnf("There was an error updating the status to finished for %s in time " +
                                        "slice [%s]", jobId, timeSlice.getTime()))
                )
                .switchIfEmpty(Observable.<ResultSet>empty().doOnCompleted(() -> logger.warnf(
                        "Attempt to update the status of a non-exist job [%s] " +
                                "in time slice [%s]", jobId, timeSlice.getTime())));

    }

    public JobDetailsImpl createJobDetails(UUID jobId, String jobType, String jobName, Map<String, String> parameters,
            Trigger trigger, Date timeSlice) {
        return createJobDetails(jobId, jobType, jobName, parameters, trigger, JobStatus.NONE, timeSlice);
    }

    public JobDetailsImpl createJobDetails(UUID jobId, String jobType, String jobName, Map<String, String> parameters,
            Trigger trigger, JobStatus status, Date timeSlice) {
        if (jobId == null || jobType == null || jobName == null || trigger == null || timeSlice == null) {
            logger.warnf("Tried to insert job on scheduled jobs with invalid values [JobId: %s, TimeSlice: %s]", jobId,
                    timeSlice);
        }
        Function<Map<String, String>, Completable> saveParameters = params ->
                session.execute(updateJobParameters.bind(params, timeSlice, jobId)).toCompletable();
        return new JobDetailsImpl(jobId,  jobType, jobName, new JobParametersImpl(parameters, SAVE_PARAMS_NO_OP),
                trigger, status);
    }

    public void prepareJobDetailsForExecution(JobDetailsImpl jobDetails, Date timeSlice) {
        if (jobDetails.getJobId() == null || jobDetails.getJobType() == null ||
                jobDetails.getJobName() == null || jobDetails.getTrigger() == null) {
            logger.warn("Tried to prepare job for execution with invalid values");
        }
        Function<Map<String, String>, Completable> saveParameters = params ->
                session.execute(updateJobParameters.bind(jobDetails.getParameters().getMap(), timeSlice,
                        jobDetails.getJobId())).toCompletable();
        jobDetails.setSaveParameters(saveParameters);
    }

    public void resetJobDetails(JobDetailsImpl jobDetails) {
        jobDetails.setSaveParameters(SAVE_PARAMS_NO_OP);
    }

    static Trigger getTrigger(UDTValue value) {
        int type = value.getInt("type");

        switch (type) {
            case 0:
                return new SingleExecutionTrigger(value.getLong("trigger_time"));
            case 1:
                return new RepeatingTrigger(
                        value.getLong("interval"),
                        value.getLong("delay"),
                        value.getLong("trigger_time"),
                        value.getInt("repeat_count"),
                        value.getInt("execution_count")
                );
            default:
                throw new IllegalArgumentException("Trigger type [" + type + "] is not supported");
        }
    }

    static UDTValue getTriggerValue(RxSession session, Trigger trigger) {
        if (trigger instanceof RepeatingTrigger) {
            return getRepeatingTriggerValue(session, (RepeatingTrigger) trigger);
        }
        if (trigger instanceof SingleExecutionTrigger) {
            return getSingleExecutionTriggerValue(session, (SingleExecutionTrigger) trigger);
        }
        throw new IllegalArgumentException(trigger.getClass() + " is not a supported trigger type");
    }

    static UDTValue getSingleExecutionTriggerValue(RxSession session, SingleExecutionTrigger trigger) {
        UserType triggerType = getKeyspace(session).getUserType("trigger_def");
        UDTValue triggerUDT = triggerType.newValue();
        triggerUDT.setInt("type", 0);
        triggerUDT.setLong("trigger_time", trigger.getTriggerTime());

        return triggerUDT;
    }

    static UDTValue getRepeatingTriggerValue(RxSession session, RepeatingTrigger trigger) {
        UserType triggerType = getKeyspace(session).getUserType("trigger_def");
        UDTValue triggerUDT = triggerType.newValue();
        triggerUDT.setInt("type", 1);
        triggerUDT.setLong("interval", trigger.getInterval());
        triggerUDT.setLong("trigger_time", trigger.getTriggerTime());
        if (trigger.getDelay() > 0) {
            triggerUDT.setLong("delay", trigger.getDelay());
        }
        if (trigger.getRepeatCount() != null) {
            triggerUDT.setInt("repeat_count", trigger.getRepeatCount());
            triggerUDT.setInt("execution_count", trigger.getExecutionCount());
        }

        return triggerUDT;
    }

    private static KeyspaceMetadata getKeyspace(RxSession session) {
        return session.getCluster().getMetadata().getKeyspace(session.getLoggedKeyspace());
    }

}


/*
 * Copyright 2014-2016 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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.hawkular.metrics.model;

import static java.lang.Double.isNaN;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;

/**
 * Statistics for metric data in a time range (bucket).
 *
 * @author Thomas Segismont
 */
public abstract class BucketPoint {
    private final long start;
    private final long end;

    protected BucketPoint(long start, long end) {
        this.start = start;
        this.end = end;
    }

    /**
     * @return start of the time range
     */
    public long getStart() {
        return start;
    }

    /**
     * @return end of the time range
     */
    public long getEnd() {
        return end;
    }

    protected Double getDoubleValue(double value) {
        if (isNaN(value)) {
            return null;
        }
        return value;
    }

    /**
     * @return returns true if there was no data to build this bucket
     */
    public abstract boolean isEmpty();

    /**
     * Converts bucket points indexed by start time into a list, ordered by start time. Blanks will be filled with
     * empty bucket points.
     */
    public static <T extends BucketPoint> List<T> toList(Map<Long, T> pointMap, Buckets buckets, BiFunction<Long,
            Long, T> emptyBucketFactory) {
        List<T> result = new ArrayList<>(buckets.getCount());
        for (int index = 0; index < buckets.getCount(); index++) {
            long from = buckets.getBucketStart(index);
            T bucketPoint = pointMap.get(from);
            if (bucketPoint == null) {
                long to = from + buckets.getStep();
                bucketPoint = emptyBucketFactory.apply(from, to);
            }
            result.add(bucketPoint);
        }
        return result;
    }
}


/*
 * Copyright 2014-2018 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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.hawkular.metrics.core.jobs;

import java.util.Map;
import java.util.concurrent.TimeUnit;

import org.hawkular.metrics.core.service.MetricsService;
import org.hawkular.metrics.scheduler.api.JobDetails;
import org.hawkular.metrics.scheduler.api.Scheduler;
import org.hawkular.metrics.scheduler.api.SingleExecutionTrigger;
import org.hawkular.metrics.sysconfig.ConfigurationService;
import org.hawkular.rx.cassandra.driver.RxSession;
import org.jboss.logging.Logger;

import com.google.common.collect.ImmutableMap;

import rx.Single;

/**
 * @author jsanda
 */
public class JobsServiceImpl implements JobsService, JobsServiceImplMBean {

    public static final String CONFIG_PREFIX = "org.hawkular.metrics.jobs.";

    private static Logger logger = Logger.getLogger(JobsServiceImpl.class);

    private Scheduler scheduler;

    private RxSession session;

    private MetricsService metricsService;

    private DeleteTenant deleteTenant;

    private ConfigurationService configurationService;

    public void setMetricsService(MetricsService metricsService) {
        this.metricsService = metricsService;
    }

    public void setSession(RxSession session) {
        this.session = session;
    }

    public void setConfigurationService(ConfigurationService configurationService) {
        this.configurationService = configurationService;
    }

    /**
     * Ideally I think the scheduler should be an implementation detail of this service. This method is here though as
     * a test hook.
     */
    public void setScheduler(Scheduler scheduler) {
        this.scheduler = scheduler;
    }

    @Override
    public void start() {
        deleteTenant = new DeleteTenant(session, metricsService);

        scheduler.register(DeleteTenant.JOB_NAME, deleteTenant);

        TempTableCreator tempCreator = new TempTableCreator(metricsService, configurationService);
        scheduler.register(TempTableCreator.JOB_NAME, tempCreator);

        TempDataCompressor tempJob = new TempDataCompressor(metricsService, configurationService);
        scheduler.register(TempDataCompressor.JOB_NAME, tempJob);

        scheduler.start();
    }

    @Override
    public void shutdown() {
        scheduler.shutdown();
    }

    @Override
    public Single<? extends JobDetails> submitDeleteTenantJob(String tenantId, String jobName) {
        return scheduler.scheduleJob(DeleteTenant.JOB_NAME, jobName, ImmutableMap.of("tenantId", tenantId),
                new SingleExecutionTrigger.Builder().withDelay(1, TimeUnit.MINUTES).build());
    }

    // JMX management
    private void submitCompressJob(Map<String, String> parameters) {
        String jobName = String.format("%s_single_%s", CompressData.JOB_NAME, parameters.get(CompressData.TARGET_TIME));
        // Blocking to ensure it is actually scheduled..
        scheduler.scheduleJob(CompressData.JOB_NAME, jobName, parameters,
                new SingleExecutionTrigger.Builder().withDelay(1, TimeUnit.MINUTES).build())
                .toBlocking().value();
    }

    @Override
    public void submitCompressJob(long timestamp) {
        logger.debugf("Scheduling manual submitCompressJob with default blocksize, timestamp->%d", timestamp);
        submitCompressJob(ImmutableMap.of(CompressData.TARGET_TIME, Long.valueOf(timestamp).toString()));
    }

    @Override
    public void submitCompressJob(long timestamp, String blockSize) {
        logger.debugf("Scheduling manual submitCompressJob with defined blocksize, timestamp->%d, blockSize->%s",
                timestamp, blockSize);
        submitCompressJob(ImmutableMap.of(
                CompressData.TARGET_TIME, Long.valueOf(timestamp).toString(),
                CompressData.BLOCK_SIZE, blockSize)
        );
    }

}


/*
 * Copyright 2014-2016 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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.hawkular.metrics.core.service;

import java.util.regex.Pattern;

/**
 * @author Thomas Segismont
 */
public class PatternUtil {

    /**
     * Allow special cases to Pattern matching, such as "*" -> ".*" and ! indicating the match shouldn't
     * happen. The first ! indicates the rest of the pattern should not match.
     *
     * @param inputRegexp Regexp given by the user
     * @return Pattern modified to allow special cases in the query language
     */
    public static Pattern filterPattern(String inputRegexp) {
        if (inputRegexp.equals("*")) {
            inputRegexp = ".*";
        } else if (inputRegexp.startsWith("!")) {
            inputRegexp = inputRegexp.substring(1);
        }
        return Pattern.compile(inputRegexp); // Catch incorrect patterns..
    }

    private PatternUtil() {
        // Utility class
    }
}


/*
 * Copyright 2014-2016 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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.hawkular.metrics.core.util;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;

import org.hawkular.metrics.sysconfig.ConfigurationService;
import org.hawkular.rx.cassandra.driver.RxSession;
import org.jboss.logging.Logger;

import com.datastax.driver.core.AggregateMetadata;
import com.datastax.driver.core.Cluster;
import com.datastax.driver.core.FunctionMetadata;
import com.datastax.driver.core.KeyspaceMetadata;
import com.datastax.driver.core.MaterializedViewMetadata;
import com.datastax.driver.core.PreparedStatement;
import com.datastax.driver.core.SchemaChangeListener;
import com.datastax.driver.core.TableMetadata;
import com.datastax.driver.core.UserType;
import com.google.common.base.Stopwatch;

import rx.Completable;
import rx.Observable;
import rx.subjects.PublishSubject;

/**
 *

 * A listener that manages gc_grace_seconds for each table. For a single node cluster gc_grace_seconds can safely be
 * set to zero for all tables. It can also be set to zero for a multi-node cluster when replication_factor = 1. We can
 * always keep gc_grace_seconds = 0 for the data_compressed table since we do append-only writes and use TTL for
 * deletes.
 *

 *

 * Checks are performed only on keyspace change events such as changes to replication_factor since we do not do any
 * schema changes like adding/altering tables at runtime after server initialization.
 *

 *
 * @author jsanda
 */
public class GCGraceSecondsManager implements SchemaChangeListener {

    public static int DEFAULT_GC_GRACE_SECONDS = 604800;

    private static Logger logger = Logger.getLogger(GCGraceSecondsManager.class);

    private RxSession session;

    private PreparedStatement getGCGraceSeconds;

    private String keyspace;

    private ConfigurationService configurationService;

    private Optional<PublishSubject<Throwable>> updatesFinished;

    public GCGraceSecondsManager(RxSession session, String keyspace, ConfigurationService configurationService) {
        this.session = session;
        this.keyspace = keyspace;
        getGCGraceSeconds = session.getSession().prepare(
                "SELECT table_name, gc_grace_seconds FROM system_schema.tables WHERE keyspace_name = ?");
        this.configurationService = configurationService;
        session.getCluster().register(this);
        updatesFinished = Optional.empty();

    }

    /**
     * Asynchronously performs gc_grace_seconds updates if necessary. This method should not be called
     * until schema updates have finished.
     */
    public void maybeUpdateGCGraceSeconds() {
        logger.info("Checking tables in " + keyspace + " to see if gc_grace_seconds needs to be updated");
        Stopwatch stopwatch = Stopwatch.createStarted();
        Map<String, String> replication = session.getCluster().getMetadata().getKeyspace(keyspace).getReplication();
        String replicationFactor = replication.get("replication_factor");
        Completable check;

        if (getClusterSize() == 1 || replicationFactor.equals("1")) {
            check = updateAllGCGraceSeconds(0);
        } else {
            // Need to call Completable.merge in order for subscriptions to happen correctly. See https://goo.gl/l15CRV
            check = Completable.merge(configurationService.load("org.hawkular.metrics", "gcGraceSeconds")
                    .switchIfEmpty(Observable.just(Integer.toString(DEFAULT_GC_GRACE_SECONDS)))
                    .map(property -> {
                        int gcGraceSeconds = Integer.parseInt(property);
                        return updateAllGCGraceSeconds(gcGraceSeconds);
                    }));
        }

        check.subscribe(
                () -> {
                    stopwatch.stop();
                    logger.info("Finished gc_grace_seconds updates in " + stopwatch.elapsed(TimeUnit.MILLISECONDS)
                            + " ms");
                    updatesFinished.ifPresent(subject -> subject.onNext(null));
                },
                t -> {
                    logger.warn("There was an error checking and updating gc_grace_seconds");
                    updatesFinished.ifPresent(subject -> subject.onNext(t));
                }
        );
    }

    /**
     * Test hook
     */
    protected int getClusterSize() {
        return session.getCluster().getMetadata().getAllHosts().size();
    }

    void setUpdatesFinishedSubject(PublishSubject<Throwable> updatesFinishedSubject) {
        updatesFinished = Optional.of(updatesFinishedSubject);
    }

    private Completable updateAllGCGraceSeconds(int gcGraceSeconds) {
        return Completable.concat(getGCGraceSeconds().map(metaData -> {
            if (metaData.tableName.equals("data_compressed")) {
                if (metaData.gcGraceSeconds == 0) {
                    return Completable.complete();
                }
                logger.info("gc_grace_seconds for " + metaData.tableName + " is set to " + metaData.gcGraceSeconds +
                        ". Resetting back to zero.");
                return updateGCGraceSeconds(metaData.tableName, 0);
            } else {
                if (metaData.gcGraceSeconds != gcGraceSeconds) {
                    logger.info("gc_grace_seconds for " + metaData.tableName + " is set to " + metaData.gcGraceSeconds
                            + ". Resetting to " + gcGraceSeconds);
                    return updateGCGraceSeconds(metaData.tableName, gcGraceSeconds);
                }
                return Completable.complete();
            }
        }));
    }

    private Observable<TableMetaData> getGCGraceSeconds() {
        return session.executeAndFetch(getGCGraceSeconds.bind(keyspace))
                .map(row -> new TableMetaData(row.getString(0), row.getInt(1)));
    }

    private Completable updateGCGraceSeconds(String table, int seconds) {
        return session.execute("ALTER TABLE " + keyspace + "." + table + " WITH gc_grace_seconds = " + seconds)
                .toCompletable();
    }

    @Override
    public void onRegister(Cluster cluster) {

    }

    @Override
    public void onUnregister(Cluster cluster) {

    }

    @Override
    public void onKeyspaceAdded(KeyspaceMetadata keyspace) {
    }

    @Override
    public void onKeyspaceRemoved(KeyspaceMetadata keyspace) {
    }

    @Override
    public void onKeyspaceChanged(KeyspaceMetadata current, KeyspaceMetadata previous) {
        if (!current.getName().equals(keyspace)) {
            return;
        }
        String oldReplicationFactor = previous.getReplication().get("replication_factor");
        String newReplicationFactor = current.getReplication().get("replication_factor");
        if (!oldReplicationFactor.equals(newReplicationFactor)) {
            logger.info("replication_factor of " + keyspace + " has changed from " + oldReplicationFactor +
                    " to " + newReplicationFactor);
            maybeUpdateGCGraceSeconds();
        }
    }

    @Override
    public void onTableAdded(TableMetadata table) {
    }

    @Override
    public void onTableRemoved(TableMetadata table) {
    }

    @Override
    public void onTableChanged(TableMetadata current, TableMetadata previous) {
    }

    @Override
    public void onUserTypeAdded(UserType type) {

    }

    @Override
    public void onUserTypeRemoved(UserType type) {

    }

    @Override
    public void onUserTypeChanged(UserType current, UserType previous) {

    }

    @Override
    public void onFunctionAdded(FunctionMetadata function) {

    }

    @Override
    public void onFunctionRemoved(FunctionMetadata function) {

    }

    @Override
    public void onFunctionChanged(FunctionMetadata current, FunctionMetadata previous) {

    }

    @Override
    public void onAggregateAdded(AggregateMetadata aggregate) {

    }

    @Override
    public void onAggregateRemoved(AggregateMetadata aggregate) {

    }

    @Override
    public void onAggregateChanged(AggregateMetadata current, AggregateMetadata previous) {

    }

    @Override
    public void onMaterializedViewAdded(MaterializedViewMetadata view) {

    }

    @Override
    public void onMaterializedViewRemoved(MaterializedViewMetadata view) {

    }

    @Override
    public void onMaterializedViewChanged(MaterializedViewMetadata current, MaterializedViewMetadata previous) {

    }

    private static class TableMetaData {
        private final String tableName;
        private final int gcGraceSeconds;

        public TableMetaData(String tableName, int gcGraceSeconds) {
            this.tableName = tableName;
            this.gcGraceSeconds = gcGraceSeconds;
        }
    }
}


/*
 * Copyright 2014-2016 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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.hawkular.openshift.auth;

import io.undertow.server.HttpServerExchange;

/**
 * @author Thomas Segismont
 */
public class Utils {

    /**
     * Changes the status code of the response and ends the exchange.
     *
     * @param exchange   the HTTP server request/response exchange
     * @param statusCode the HTTP status code
     *
     * @see HttpServerExchange#setStatusCode(int)
     * @see HttpServerExchange#endExchange()
     */
    public static void endExchange(HttpServerExchange exchange, int statusCode) {
        endExchange(exchange, statusCode, null);
    }

    /**
     * Changes the status code of the response, sets the HTTP reason phrase and ends the exchange.
     *
     * @param exchange     the HTTP server request/response exchange
     * @param statusCode   the HTTP status code
     * @param reasonPhrase the HTTP status message
     *
     * @see HttpServerExchange#setStatusCode(int)
     * @see HttpServerExchange#setReasonPhrase(String)
     * @see HttpServerExchange#endExchange()
     */
    public static void endExchange(HttpServerExchange exchange, int statusCode, String reasonPhrase) {
        exchange.setStatusCode(statusCode);
        if (reasonPhrase != null) {
            exchange.setReasonPhrase(reasonPhrase);
        }
        exchange.endExchange();
    }

    private Utils() {
        // Utility class
    }
}


/*
 * Copyright 2014-2015 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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.hawkular.metrics.model;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Describes an interval or duration intended to be used with aggregated metrics. An interval describes the frequency
 * at which aggregated metrics are computed/updated. It consists of two parts - length and units. Examples include,
 *
 *

 *
1min

 *
5min

 *
1hr

 *
5hr

 *
1d

 *
5d

 *

 *
 * where min denotes minute, hr denotes hour, and d denotes day.
 *
 * @author John Sanda
 */
public class Interval {

    private static final Pattern INTERVAL_PATTERN = Pattern.compile("(\\d+)(min|hr|d)");

    public static enum Units {
        MINUTES("min"),

        HOURS("hr"),

        DAYS("d");

        private String code;

        private Units(String code) {
            this.code = code;
        }

        public String getCode() {
            return code;
        }

        public static Units fromCode(String code) {
            switch (code) {
                case "min": return MINUTES;
                case "hr": return HOURS;
                case "d": return DAYS;
                default: throw new IllegalArgumentException(code + " is not a recognized unit");
            }
        }
    }

    public static final Interval NONE = new Interval(0, null) {
        @Override
        public String toString() {
            return "";
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;

            Interval interval = (Interval) o;
            return interval.length == 0 && interval.units == null;
        }

        @Override
        public int hashCode() {
            return 51;
        }
    };

    private int length;

    private Units units;

    public Interval(int length, Units units) {
        this.length = length;
        this.units = units;
    }

    /**
     * Parses the string into an interval. The string must match the regular expression (\d+)(min|hr|d); otherwise,
     * an exception is thrown.
     *
     * @param s The string to parse
     * @return The {@link Interval}
     * @throws java.lang.IllegalArgumentException if the string does not parse
     */
    public static Interval parse(String s) {
        if (s.isEmpty()) {
            return NONE;
        }

        Matcher matcher = INTERVAL_PATTERN.matcher(s);
        if (!(matcher.matches() && matcher.groupCount() == 2)) {
            throw new IllegalArgumentException(s + " is not a valid interval. It must follow the pattern " +
                INTERVAL_PATTERN.pattern());
        }
        return new Interval(Integer.parseInt(matcher.group(1)), Units.fromCode(matcher.group(2)));
    }

    public int getLength() {
        return length;
    }

    public Units getUnits() {
        return units;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Interval interval = (Interval) o;

        if (length != interval.length) return false;
        if (units != interval.units) return false;

        return true;
    }

    @Override
    public int hashCode() {
        int result = length;
        result = 31 * result + units.hashCode();
        return result;
    }

    @Override
    public String toString() {
        return length + units.code;
    }
}