package org.fxmisc.flowless;

import java.util.Optional;
import java.util.OptionalInt;

import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.layout.Region;

import org.fxmisc.flowless.VirtualFlow.Gravity;
import org.reactfx.Subscription;
import org.reactfx.collection.LiveList;
import org.reactfx.collection.MemoizationList;
import org.reactfx.collection.QuasiListChange;
import org.reactfx.collection.QuasiListModification;

/**
 * Responsible for laying out cells' nodes within the viewport based on a single anchor node. In a layout call,
 * this anchor node is positioned in the viewport before any other node and then nodes are positioned above and
 * below that anchor node sequentially. This sequential layout continues until the viewport's "top" and "bottom" edges
 * are reached or there are no other cells' nodes to render. In this latter case (when there is not enough content to
 * fill up the entire viewport), the displayed cells are repositioned towards the "ground," based on the
 * {@link VirtualFlow}'s {@link Gravity} value, and any remaining unused space counts as the "sky."
 */
final class Navigator<T, C extends Cell<T, ?>>
extends Region implements TargetPositionVisitor {
    private final CellListManager<T, C> cellListManager;
    private final MemoizationList<C> cells;
    private final CellPositioner<T, C> positioner;
    private final OrientationHelper orientation;
    private final ObjectProperty<Gravity> gravity;
    private final SizeTracker sizeTracker;
    private final Subscription itemsSubscription;

    private TargetPosition currentPosition = TargetPosition.BEGINNING;
    private TargetPosition targetPosition = TargetPosition.BEGINNING;
    private int firstVisibleIndex = -1;
    private int lastVisibleIndex = -1;

    public Navigator(
            CellListManager<T, C> cellListManager,
            CellPositioner<T, C> positioner,
            OrientationHelper orientation,
            ObjectProperty<Gravity> gravity,
            SizeTracker sizeTracker) {
        this.cellListManager = cellListManager;
        this.cells = cellListManager.getLazyCellList();
        this.positioner = positioner;
        this.orientation = orientation;
        this.gravity = gravity;
        this.sizeTracker = sizeTracker;

        this.itemsSubscription = LiveList.observeQuasiChanges(cellListManager.getLazyCellList(), this::itemsChanged);
        Bindings.bindContent(getChildren(), cellListManager.getNodes());
        // When gravity changes, we must redo our layout:
        gravity.addListener((prop, oldVal, newVal) -> requestLayout());
    }

    public void dispose() {
        itemsSubscription.unsubscribe();
        Bindings.unbindContent(getChildren(), cellListManager.getNodes());
    }

    @Override
    protected void layoutChildren() {
        // invalidate breadth for each cell that has dirty layout
        int n = cells.getMemoizedCount();
        for(int i = 0; i < n; ++i) {
            int j = cells.indexOfMemoizedItem(i);
            Node node = cells.get(j).getNode();
            if(node instanceof Parent && ((Parent) node).isNeedsLayout()) {
                sizeTracker.forgetSizeOf(j);
            }
        }

        if(!cells.isEmpty()) {
            targetPosition.clamp(cells.size())
                    .accept(this);
        }
        currentPosition = getCurrentPosition();
        targetPosition = currentPosition;
    }

    /**
     * Sets the {@link TargetPosition} used to layout the anchor node and re-lays out the viewport
     */
    public void setTargetPosition(TargetPosition targetPosition) {
        this.targetPosition = targetPosition;
        requestLayout();
    }

    /**
     * Sets the {@link TargetPosition} used to layout the anchor node to the current position scrolled by {@code delta}
     * and re-lays out the viewport
     */
    public void scrollCurrentPositionBy(double delta) {
        targetPosition = currentPosition.scrollBy(delta);
        requestLayout();
    }

    private TargetPosition getCurrentPosition() {
        if (cellListManager.getLazyCellList().getMemoizedCount() == 0) {
            return TargetPosition.BEGINNING;
        }
        else {
            C cell = positioner.getVisibleCell(firstVisibleIndex);
            return new StartOffStart(firstVisibleIndex, orientation.minY(cell));
        }
    }

    private void itemsChanged(QuasiListChange<?> ch) {
        for(QuasiListModification<?> mod: ch) {
            targetPosition = targetPosition.transformByChange(
                    mod.getFrom(), mod.getRemovedSize(), mod.getAddedSize());
        }
        requestLayout(); // TODO: could optimize to only request layout if
                         // target position changed or cells in the viewport
                         // are affected
    }

    void showLengthRegion(int itemIndex, double fromY, double toY) {
        setTargetPosition(new MinDistanceTo(
                itemIndex, Offset.fromStart(fromY), Offset.fromStart(toY)));
    }

    @Override
    public void visit(StartOffStart targetPosition) {
        placeStartAtMayCrop(targetPosition.itemIndex, targetPosition.offsetFromStart);
        fillViewportFrom(targetPosition.itemIndex);
    }

    @Override
    public void visit(EndOffEnd targetPosition) {
        placeEndOffEndMayCrop(targetPosition.itemIndex, targetPosition.offsetFromEnd);
        fillViewportFrom(targetPosition.itemIndex);
    }

    @Override
    public void visit(MinDistanceTo targetPosition) {
        Optional<C> cell = positioner.getCellIfVisible(targetPosition.itemIndex);
        if(cell.isPresent()) {
            placeToViewport(targetPosition.itemIndex, targetPosition.minY, targetPosition.maxY);
        } else {
            OptionalInt prevVisible;
            OptionalInt nextVisible;
            if((prevVisible = positioner.lastVisibleBefore(targetPosition.itemIndex)).isPresent()) {
                // Try keeping prevVisible in place:
                // fill the viewport, see if the target item appeared.
                fillForwardFrom(prevVisible.getAsInt());
                cell = positioner.getCellIfVisible(targetPosition.itemIndex);
                if(cell.isPresent()) {
                    placeToViewport(targetPosition.itemIndex, targetPosition.minY, targetPosition.maxY);
                } else if(targetPosition.maxY.isFromStart()) {
                    placeStartOffEndMayCrop(targetPosition.itemIndex, -targetPosition.maxY.getValue());
                } else {
                    placeEndOffEndMayCrop(targetPosition.itemIndex, -targetPosition.maxY.getValue());
                }
            } else if((nextVisible = positioner.firstVisibleAfter(targetPosition.itemIndex + 1)).isPresent()) {
                // Try keeping nextVisible in place:
                // fill the viewport, see if the target item appeared.
                fillBackwardFrom(nextVisible.getAsInt());
                cell = positioner.getCellIfVisible(targetPosition.itemIndex);
                if(cell.isPresent()) {
                    placeToViewport(targetPosition.itemIndex, targetPosition.minY, targetPosition.maxY);
                } else if(targetPosition.minY.isFromStart()) {
                    placeStartAtMayCrop(targetPosition.itemIndex, -targetPosition.minY.getValue());
                } else {
                    placeEndOffStartMayCrop(targetPosition.itemIndex, -targetPosition.minY.getValue());
                }
            } else {
                if(targetPosition.minY.isFromStart()) {
                    placeStartAtMayCrop(targetPosition.itemIndex, -targetPosition.minY.getValue());
                } else {
                    placeEndOffStartMayCrop(targetPosition.itemIndex, -targetPosition.minY.getValue());
                }
            }
        }
        fillViewportFrom(targetPosition.itemIndex);
    }

    /**
     * Get the index of the first visible cell (at the time of the last layout).
     * 
     * @return The index of the first visible cell
     */
    public int getFirstVisibleIndex() {
        return firstVisibleIndex;
    }
    
    /**
     * Get the index of the last visible cell (at the time of the last layout).
     * 
     * @return The index of the last visible cell
     */
    public int getLastVisibleIndex() {
        return lastVisibleIndex;
    }
    
    private void placeToViewport(int itemIndex, Offset from, Offset to) {
        C cell = positioner.getVisibleCell(itemIndex);
        double fromY = from.isFromStart()
                ? from.getValue()
                : orientation.length(cell) + to.getValue();
        double toY = to.isFromStart()
                ? to.getValue()
                : orientation.length(cell) + to.getValue();
        placeToViewport(itemIndex, fromY, toY);
    }

    private void placeToViewport(int itemIndex, double fromY, double toY) {
        C cell = positioner.getVisibleCell(itemIndex);
        double d = positioner.shortestDeltaToViewport(cell, fromY, toY);
        positioner.placeStartAt(itemIndex, orientation.minY(cell) + d);
    }

    private void placeStartAtMayCrop(int itemIndex, double startOffStart) {
        cropToNeighborhoodOf(itemIndex, startOffStart);
        positioner.placeStartAt(itemIndex, startOffStart);
    }

    private void placeStartOffEndMayCrop(int itemIndex, double startOffEnd) {
        cropToNeighborhoodOf(itemIndex, startOffEnd);
        positioner.placeStartFromEnd(itemIndex, startOffEnd);
    }

    private void placeEndOffStartMayCrop(int itemIndex, double endOffStart) {
        cropToNeighborhoodOf(itemIndex, endOffStart);
        positioner.placeEndFromStart(itemIndex, endOffStart);
    }

    private void placeEndOffEndMayCrop(int itemIndex, double endOffEnd) {
        cropToNeighborhoodOf(itemIndex, endOffEnd);
        positioner.placeEndFromEnd(itemIndex, endOffEnd);
    }

    private void cropToNeighborhoodOf(int itemIndex, double additionalOffset) {
        double spaceBefore = Math.max(0, sizeTracker.getViewportLength() + additionalOffset);
        double spaceAfter = Math.max(0, sizeTracker.getViewportLength() - additionalOffset);

        Optional<Double> avgLen = sizeTracker.getAverageLengthEstimate();
        int itemsBefore = avgLen.map(l -> spaceBefore/l).orElse(5.0).intValue();
        int itemsAfter = avgLen.map(l -> spaceAfter/l).orElse(5.0).intValue();

        positioner.cropTo(itemIndex - itemsBefore, itemIndex + 1 + itemsAfter);
    }

    private int fillForwardFrom(int itemIndex) {
        return fillForwardFrom(itemIndex, sizeTracker.getViewportLength());
    }

    private int fillForwardFrom0(int itemIndex) {
        return fillForwardFrom0(itemIndex, sizeTracker.getViewportLength());
    }

    private int fillForwardFrom(int itemIndex, double upTo) {
        // resize and/or reposition the starting cell
        // in case the preferred or available size changed
        C cell = positioner.getVisibleCell(itemIndex);
        double length0 = orientation.minY(cell);
        positioner.placeStartAt(itemIndex, length0);

        return fillForwardFrom0(itemIndex, upTo);
    }

    int fillForwardFrom0(int itemIndex, double upTo) {
        double max = orientation.maxY(positioner.getVisibleCell(itemIndex));
        int i = itemIndex;
        while(max < upTo && i < cellListManager.getLazyCellList().size() - 1) {
            ++i;
            C c = positioner.placeStartAt(i, max);
            max = orientation.maxY(c);
        }
        return i;
    }

    private int fillBackwardFrom(int itemIndex) {
        return fillBackwardFrom(itemIndex, 0.0);
    }

    private int fillBackwardFrom0(int itemIndex) {
        return fillBackwardFrom0(itemIndex, 0.0);
    }

    private int fillBackwardFrom(int itemIndex, double upTo) {
        // resize and/or reposition the starting cell
        // in case the preferred or available size changed
        C cell = positioner.getVisibleCell(itemIndex);
        double length0 = orientation.minY(cell);
        positioner.placeStartAt(itemIndex, length0);

        return fillBackwardFrom0(itemIndex, upTo);
    }

    // does not re-place the anchor cell
    int fillBackwardFrom0(int itemIndex, double upTo) {
        double min = orientation.minY(positioner.getVisibleCell(itemIndex));
        int i = itemIndex;
        while(min > upTo && i > 0) {
            --i;
            C c = positioner.placeEndFromStart(i, min);
            min = orientation.minY(c);
        }
        return i;
    }

    /**
     * Starting from the anchor cell's node, fills the viewport from the anchor to the "ground" and then from the anchor
     * to the "sky".
     *
     * @param itemIndex the index of the anchor cell
     */
    private void fillViewportFrom(int itemIndex) {
        // cell for itemIndex is assumed to be placed correctly

        // fill up to the ground
        int ground = fillTowardsGroundFrom0(itemIndex);

        // if ground not reached, shift cells to the ground
        double gapBefore = distanceFromGround(ground);
        if(gapBefore > 0) {
            shiftCellsTowardsGround(ground, itemIndex, gapBefore);
        }

        // fill up to the sky
        int sky = fillTowardsSkyFrom0(itemIndex);

        // if sky not reached, add more cells under the ground and then shift
        double gapAfter = distanceFromSky(sky);
        if(gapAfter > 0) {
            ground = fillTowardsGroundFrom0(ground, -gapAfter);
            double extraBefore = -distanceFromGround(ground);
            double shift = Math.min(gapAfter, extraBefore);
            shiftCellsTowardsGround(ground, sky, -shift);
        }

        // crop to the visible cells
        int first = Math.min(ground, sky);
        int last = Math.max(ground, sky);
        while(first < last &&
                orientation.maxY(positioner.getVisibleCell(first)) <= 0.0) {
            ++first;
        }
        while(last > first &&
                orientation.minY(positioner.getVisibleCell(last)) >= sizeTracker.getViewportLength()) {
            --last;
        }
        firstVisibleIndex = first;
        lastVisibleIndex = last;
        positioner.cropTo(first, last + 1);
    }

    private int fillTowardsGroundFrom0(int itemIndex) {
        return gravity.get() == Gravity.FRONT
                ? fillBackwardFrom0(itemIndex)
                : fillForwardFrom0(itemIndex);
    }

    private int fillTowardsGroundFrom0(int itemIndex, double upTo) {
        return gravity.get() == Gravity.FRONT
                ? fillBackwardFrom0(itemIndex, upTo)
                : fillForwardFrom0(itemIndex, sizeTracker.getViewportLength() - upTo);
    }

    private int fillTowardsSkyFrom0(int itemIndex) {
        return gravity.get() == Gravity.FRONT
                ? fillForwardFrom0(itemIndex)
                : fillBackwardFrom0(itemIndex);
    }

    private double distanceFromGround(int itemIndex) {
        C cell = positioner.getVisibleCell(itemIndex);
        return gravity.get() == Gravity.FRONT
                ? orientation.minY(cell)
                : sizeTracker.getViewportLength() - orientation.maxY(cell);
    }

    private double distanceFromSky(int itemIndex) {
        C cell = positioner.getVisibleCell(itemIndex);
        return gravity.get() == Gravity.FRONT
                ? sizeTracker.getViewportLength() - orientation.maxY(cell)
                : orientation.minY(cell);
    }

    private void shiftCellsTowardsGround(
            int groundCellIndex, int lastCellIndex, double amount) {
        if(gravity.get() == Gravity.FRONT) {
            assert groundCellIndex <= lastCellIndex;
            for(int i = groundCellIndex; i <= lastCellIndex; ++i) {
                positioner.shiftCellBy(positioner.getVisibleCell(i), -amount);
            }
        } else {
            assert groundCellIndex >= lastCellIndex;
            for(int i = groundCellIndex; i >= lastCellIndex; --i) {
                positioner.shiftCellBy(positioner.getVisibleCell(i), amount);
            }
        }
    }
}