package org.fxmisc.undo.impl;

import java.time.Duration;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;

import javafx.beans.binding.BooleanBinding;
import javafx.beans.value.ObservableBooleanValue;

import org.fxmisc.undo.UndoManager;
import org.fxmisc.undo.impl.ChangeQueue.QueuePosition;
import org.reactfx.EventSource;
import org.reactfx.EventStream;
import org.reactfx.Subscription;
import org.reactfx.SuspendableNo;
import org.reactfx.value.Val;
import org.reactfx.value.ValBase;

/**
 * Implementation for {@link UndoManager} for single changes. For multiple changes, see
 * {@link MultiChangeUndoManagerImpl}.
 *
 * @param <C> the type of change to undo/redo
 */
public class UndoManagerImpl<C> implements UndoManager<C> {

    private class UndoPositionImpl implements UndoPosition {
        private final QueuePosition queuePos;

        UndoPositionImpl(QueuePosition queuePos) {
            this.queuePos = queuePos;
        }

        @Override
        public void mark() {
            mark = queuePos;
            canMerge = false;
            atMarkedPosition.invalidate();
        }

        @Override
        public boolean isValid() {
            return queuePos.isValid();
        }
    }

    private final ChangeQueue<C> queue;
    private final Function<? super C, ? extends C> invert;
    private final Consumer<C> apply;
    private final BiFunction<C, C, Optional<C>> merge;
    private final Predicate<C> isIdentity;
    private final Subscription subscription;
    private final SuspendableNo performingAction = new SuspendableNo();

    private final EventSource<Void> invalidationRequests = new EventSource<Void>();

    private final Val<C> nextUndo = new ValBase<C>() {
        @Override protected Subscription connect() { return invalidationRequests.subscribe(x -> invalidate()); }
        @Override protected C computeValue() { return queue.hasPrev() ? queue.peekPrev() : null; }
    };

    private final Val<C> nextRedo = new ValBase<C>() {
        @Override protected Subscription connect() { return invalidationRequests.subscribe(x -> invalidate()); }
        @Override protected C computeValue() { return queue.hasNext() ? queue.peekNext() : null; }
    };

    private final BooleanBinding atMarkedPosition = new BooleanBinding() {
        { invalidationRequests.addObserver(x -> this.invalidate()); }

        @Override
        protected boolean computeValue() {
            return mark.equals(queue.getCurrentPosition());
        }
    };

    private boolean canMerge;
    private QueuePosition mark;
    private C expectedChange = null;

    public UndoManagerImpl(
            ChangeQueue<C> queue,
            Function<? super C, ? extends C> invert,
            Consumer<C> apply,
            BiFunction<C, C, Optional<C>> merge,
            Predicate<C> isIdentity,
            EventStream<C> changeSource) {
        this(queue, invert, apply, merge, isIdentity, changeSource, Duration.ZERO);
    }

    public UndoManagerImpl(
            ChangeQueue<C> queue,
            Function<? super C, ? extends C> invert,
            Consumer<C> apply,
            BiFunction<C, C, Optional<C>> merge,
            Predicate<C> isIdentity,
            EventStream<C> changeSource,
            Duration preventMergeDelay) {
        this.queue = queue;
        this.invert = invert;
        this.apply = apply;
        this.merge = merge;
        this.isIdentity = isIdentity;
        this.mark = queue.getCurrentPosition();

        Subscription mainSub = changeSource.subscribe(this::changeObserved);

        if (preventMergeDelay.isZero() || preventMergeDelay.isNegative()) {
            subscription = mainSub;
        } else {
            Subscription sub2 = changeSource.successionEnds(preventMergeDelay).subscribe(ignore -> preventMerge());
            subscription = mainSub.and(sub2);
        }
    }

    @Override
    public void close() {
        subscription.unsubscribe();
    }

    @Override
    public boolean undo() {
        return applyChange(isUndoAvailable(), () -> invert.apply(queue.prev()));
    }

    @Override
    public boolean redo() {
        return applyChange(isRedoAvailable(), queue::next);
    }

    @Override
    public Val<C> nextUndoProperty() {
        return nextUndo;
    }

    @Override
    public Val<C> nextRedoProperty() {
        return nextRedo;
    }

    @Override
    public boolean isUndoAvailable() {
        return nextUndo.isPresent();
    }

    @Override
    public Val<Boolean> undoAvailableProperty() {
        return nextUndo.map(c -> true).orElseConst(false);
    }

    @Override
    public boolean isRedoAvailable() {
        return nextRedo.isPresent();
    }

    @Override
    public Val<Boolean> redoAvailableProperty() {
        return nextRedo.map(c -> true).orElseConst(false);
    }

    @Override
    public boolean isPerformingAction() {
        return performingAction.get();
    }

    @Override
    public ObservableBooleanValue performingActionProperty() {
        return performingAction;
    }

    @Override
    public boolean isAtMarkedPosition() {
        return atMarkedPosition.get();
    }

    @Override
    public ObservableBooleanValue atMarkedPositionProperty() {
        return atMarkedPosition;
    }

    @Override
    public UndoPosition getCurrentPosition() {
        return new UndoPositionImpl(queue.getCurrentPosition());
    }

    @Override
    public void preventMerge() {
        canMerge = false;
    }

    @Override
    public void forgetHistory() {
        queue.forgetHistory();
        invalidateProperties();
    }

    /**
     * Helper method for reducing code duplication
     *
     * @param isChangeAvailable same as `isUndoAvailable()` [Undo] or `isRedoAvailable()` [Redo]
     * @param changeToApply same as `invert.apply(queue.prev())` [Undo] or `queue.next()` [Redo]
     */
    private boolean applyChange(boolean isChangeAvailable, Supplier<C> changeToApply) {
        if (isChangeAvailable) {
            canMerge = false;

            // perform change
            C change = changeToApply.get();
            this.expectedChange = change;
            performingAction.suspendWhile(() -> apply.accept(change));
            if(this.expectedChange != null) {
                throw new IllegalStateException("Expected change not received:\n"
                        + this.expectedChange);
            }

            invalidateProperties();
            return true;
        } else {
            return false;
        }
    }

    private void changeObserved(C change) {
        if(expectedChange == null) {
            if (!isIdentity.test(change)) {
                addChange(change);
            }
        } else if(expectedChange.equals(change)) {
            expectedChange = null;
        } else {
            throw new IllegalArgumentException("Unexpected change received."
                    + "\nExpected:\n" + expectedChange
                    + "\nReceived:\n" + change);
        }
    }

    @SuppressWarnings("unchecked")
    private void addChange(C change) {
        if(canMerge && queue.hasPrev()) {
            C prev = queue.prev();

            // attempt to merge the changes
            Optional<C> merged = merge.apply(prev, change);
            if(merged.isPresent()) {
                if (isIdentity.test(merged.get())) {
                    canMerge = false;
                    queue.push(); // clears the future
                } else {
                    canMerge = true;
                    queue.push(merged.get());
                }
            } else {
                canMerge = true;
                queue.next();
                queue.push(change);
            }
        } else {
            queue.push(change);
            canMerge = true;
        }
        invalidateProperties();
    }

    private void invalidateProperties() {
        invalidationRequests.push(null);
    }
}