package tc.oc.pgm.countdowns;

import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import javax.inject.Inject;

import org.bukkit.event.EventException;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import tc.oc.commons.core.IterableUtils;
import tc.oc.commons.core.concurrent.SerializingExecutor;
import tc.oc.commons.core.logging.Loggers;
import tc.oc.commons.core.scheduler.Scheduler;
import tc.oc.commons.core.scheduler.Task;
import tc.oc.commons.core.util.Comparables;
import tc.oc.commons.core.util.Predicates;
import tc.oc.commons.core.util.Streams;
import tc.oc.commons.core.util.TimeUtils;
import tc.oc.pgm.events.ListenerScope;
import tc.oc.pgm.match.MatchScope;
import tc.oc.pgm.time.TickClock;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

@ListenerScope(MatchScope.LOADED)
public abstract class CountdownContext implements Listener {

    public static final Duration MIN_REPEAT_INTERVAL = Duration.ofMillis(50);

    protected Logger logger;
    @Inject void setLogger(Loggers loggers) { logger = loggers.get(getClass()); }

    @Inject protected Scheduler scheduler;
    @Inject protected TickClock clock;

    /**
     * Must allow concurrent modification
     */
    protected abstract Stream<Runner> runners();

    protected abstract Optional<Runner> runner(Countdown countdown);

    protected abstract void addRunner(Runner runner);

    protected abstract void removeRunner(Runner runner);

    public abstract Set<Countdown> getAll();

    public void start(Countdown countdown, int seconds) {
        start(countdown, Duration.ofSeconds(seconds));
    }

    public void start(Countdown countdown, Duration duration) {
        start(countdown, duration, duration);
    }

    public void start(Countdown countdown, Duration initialDuration, @Nullable Duration repeatDuration) {
        start(countdown, initialDuration, repeatDuration, 1);
    }

    /**
     * Start running the given {@link Countdown} in this context. If the given Countdown
     * is already running, it will be cancelled and restarted with the given duration.
     */
    public void start(Countdown countdown, Duration initialDuration, @Nullable Duration repeatDuration, int repeatCount) {
        cancel(countdown);
        new Runner(countdown, initialDuration, repeatDuration != null ? repeatDuration : initialDuration, repeatCount);
    }

    public void cancel(Countdown countdown) {
        runner(countdown).ifPresent(r -> r.cancel(false));
    }

    public Stream<Countdown> countdowns() {
        return getAll().stream();
    }

    public <T extends Countdown> Stream<T> countdowns(Class<T> type) {
        return Streams.instancesOf(countdowns(), type);
    }

    public <T extends Countdown> Set<T> getAll(Class<T> type) {
        return IterableUtils.instancesOf(getAll(), type);
    }

    public @Nullable Duration getTimeLeft(Countdown countdown) {
        return runner(countdown).map(runner -> Duration.ofSeconds(runner.secondsRemaining))
                                .orElse(null);
    }

    public boolean isRunning(Countdown countdown) {
        return runner(countdown).filter(runner -> runner.secondsRemaining > 0)
                                .isPresent();
    }

    public boolean anyRunning() {
        return anyRunning(Predicates.alwaysTrue());
    }

    public boolean anyRunning(Predicate<? super Countdown> test) {
        return countdowns().anyMatch(test);
    }

    public boolean anyRunning(Class<? extends Countdown> countdownClass) {
        return anyRunning(countdown -> countdown.getClass().equals(countdownClass));
    }

    public void cancelAll() {
        cancelAll(false);
    }

    public void cancelAll(boolean manual) {
        if(anyRunning()) {
            logger.fine("Cancelling all countdowns");
            runners().forEach(runner -> runner.cancel(manual));
        }
    }

    /**
     * Cancel all countdowns of the given type
     * @return true if any countdowns were cancelled
     */
    public boolean cancelAll(Class<? extends Countdown> type) {
        if(anyRunning(type::isInstance)) {
            logger.fine("Cancelling all " + type.getSimpleName() + " countdowns");
            runners().filter(r -> type.isInstance(r.countdown))
                     .forEach(r -> r.cancel(false));
            return true;
        }
        return false;
    }

    /**
     * Cancel all countdowns that are not of the given type
     * @return true if any countdowns were cancelled
     */
    public boolean continueAll(Class<? extends Countdown> type) {
        if(anyRunning(c -> !type.isInstance(c))) {
            logger.fine("Cancelling all countdowns except " + type.getSimpleName());
            runners().filter(r -> !type.isInstance(r.countdown))
                     .forEach(r -> r.cancel(false));
            return true;
        }
        return false;
    }

    @EventHandler
    void suspend(ServerSuspendEvent event) throws EventException {
        try { event.yield(); }
        finally {
            runners().forEach(Runner::resume);
        }
    }

    protected class Runner implements Runnable {

        protected final Countdown countdown;
        protected final Duration initialDuration;
        protected final Duration repeatDuration;
        private final int repeatCount;

        private int count;
        private Duration duration;
        private Instant startedAt;
        private Instant willEndAt;

        // The remaining seconds that will be passed to onTick for the next cycle
        private long secondsRemaining;

        // Update task, replaced on every update
        private @Nullable Task task = null;

        // Ensures that only one callback is executing at a time for this countdown.
        // So, e.g. if the onStart callback cancels the countdown, onCancel won't
        // run until onStart returns.
        private final SerializingExecutor notifyExecutor = new SerializingExecutor();

        Runner(Countdown countdown, Duration initialDuration, Duration repeatDuration, int repeatCount) {
            checkArgument(!(repeatCount > 1 && Comparables.lessThan(repeatDuration, MIN_REPEAT_INTERVAL)));

            this.countdown = checkNotNull(countdown);
            this.initialDuration = checkNotNull(initialDuration);
            this.repeatDuration = checkNotNull(repeatDuration);
            this.repeatCount = repeatCount;

            restart();
        }

        void restart() {
            ++count;
            if(repeatCount == Integer.MAX_VALUE || count <= repeatCount) {
                duration = count == 1 ? initialDuration : repeatDuration;
                startedAt = clock.now().instant;

                if(TimeUtils.isInfPositive(duration)) {
                    willEndAt = TimeUtils.INF_FUTURE;
                    secondsRemaining = Long.MAX_VALUE;
                } else {
                    willEndAt = startedAt.plus(duration);
                    secondsRemaining = duration.getSeconds();
                    task = scheduler.createTask(this);
                }

                if(logger.isLoggable(Level.FINE)) {
                    logger.fine("Starting countdown " + countdown +
                                " at " + startedAt +
                                " for " + duration);
                }

                addRunner(this);

                notify(() -> countdown.onStart(duration, duration));
            }
        }

        protected Countdown countdown() {
            return countdown;
        }

        protected Duration remaining() {
            return TimeUtils.duration(clock.now().instant, willEndAt);
        }

        protected void cancel(boolean manual) {
            logger.fine("Cancelling countdown " + countdown);

            final Duration remaining = TimeUtils.duration(clock.now().instant, willEndAt);

            if(task != null) {
                task.cancel();
                task = null;
            }

            removeRunner(this);

            notify(() -> countdown.onCancel(remaining, duration, manual));
        }

        @Override
        public void run() {
            if(task == null) return;
            task = null;

            if(secondsRemaining < 0) return;

            // Get the total ticks remaining in the countdown
            long ticksRemaining = Math.round(remaining().toMillis() / 50d);

            // Handle any cycles since the last one
            for(;secondsRemaining >= 0 && secondsRemaining * 20 >= ticksRemaining; secondsRemaining--) {
                countdown.onTick(Duration.ofSeconds(secondsRemaining), duration);
            }

            if(secondsRemaining >= 0) {
                // If there are cycles left, schedule the next run
                long ticks = ticksRemaining - secondsRemaining * 20;
                task = scheduler.createDelayedTask(ticks < 1 ? 1 : ticks, this);
            } else {
                // Otherwise, end the countdown
                logger.fine("Ending countdown " + countdown);

                secondsRemaining = 0;

                // Remove from context before calling onEnd, so if it starts another
                // countdown, it won't try to cancel this one.
                if(count >= repeatCount) {
                    removeRunner(this);
                }

                notify(() -> countdown.onEnd(duration));

                restart();
            }
        }

        void notify(Runnable callback) {
            try {
                notifyExecutor.execute(callback);
            } catch(Throwable e) {
                logger.log(Level.SEVERE, "Exception notifying countdown " + countdown, e);
            }
        }

        void resume() {
            // Skip all the ticks that happened while suspended
            secondsRemaining = remaining().getSeconds();
        }
    }
}
