// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.plugins.turnlanes.gui;

import static java.lang.Math.PI;
import static java.lang.Math.abs;
import static java.lang.Math.hypot;
import static java.lang.Math.max;
import static java.lang.Math.tan;
import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.angle;
import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.closest;
import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.cpf;
import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.intersection;
import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.loc;
import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.normalize;
import static org.openstreetmap.josm.plugins.turnlanes.gui.GuiUtil.relativePoint;

import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.geom.FlatteningPathIterator;
import java.awt.geom.Line2D;
import java.awt.geom.Path2D;
import java.awt.geom.PathIterator;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Set;
import java.util.TreeMap;

import org.openstreetmap.josm.plugins.turnlanes.model.Junction;
import org.openstreetmap.josm.plugins.turnlanes.model.Road;
import org.openstreetmap.josm.plugins.turnlanes.model.Turn;

class JunctionGui {
    private final class TurnConnection extends InteractiveElement {
        private final Turn turn;

        private Point2D dragBegin;
        private double dragOffsetX = 0;
        private double dragOffsetY = 0;

        TurnConnection(Turn turn) {
            this.turn = turn;
        }

        @Override
        void paint(Graphics2D g2d, State state) {
            if (isVisible(state)) {
                g2d.setStroke(getContainer().getConnectionStroke());
                g2d.setColor(isRemoveDragOffset() ? GuiContainer.RED : GuiContainer.GREEN);
                g2d.translate(dragOffsetX, dragOffsetY);
                g2d.draw(getPath());
                g2d.translate(-dragOffsetX, -dragOffsetY);
            }
        }

        private Path2D getPath() {
            final Path2D path = new Path2D.Double();

            final LaneGui laneGui = getContainer().getGui(turn.getFrom());
            final RoadGui roadGui = getContainer().getGui(turn.getTo().getRoad());

            path.moveTo(laneGui.outgoing.getCenter().getX(), laneGui.outgoing.getCenter().getY());

            Junction j = laneGui.getModel().getOutgoingJunction();
            for (Road v : turn.getVia()) {
                final PathIterator it;
                if (v.getFromEnd().getJunction().equals(j)) {
                    it = getContainer().getGui(v).getLaneMiddle(true).getIterator();
                    j = v.getToEnd().getJunction();
                } else {
                    it = getContainer().getGui(v).getLaneMiddle(false).getIterator();
                    j = v.getFromEnd().getJunction();
                }

                path.append(it, true);
            }

            path.lineTo(roadGui.getConnector(turn.getTo()).getCenter().getX(), roadGui.getConnector(turn.getTo()).getCenter()
                .getY());

            return path;
        }

        private boolean isVisible(State state) {
            if (state instanceof State.AllTurns) {
                return true;
            } else if (state instanceof State.OutgoingActive) {
                return turn.getFrom().equals(((State.OutgoingActive) state).getLane().getModel());
            } else if (state instanceof State.IncomingActive) {
                return turn.getTo().equals(((State.IncomingActive) state).getRoadEnd());
            }

            return false;
        }

        @Override
        boolean contains(Point2D p, State state) {
            if (!isVisible(state)) {
                return false;
            }

            final PathIterator it = new FlatteningPathIterator(getPath().getPathIterator(null), 0.05 / getContainer()
                .getMpp());
            final double[] coords = new double[6];
            double lastX = 0;
            double lastY = 0;
            while (!it.isDone()) {
                if (it.currentSegment(coords) == PathIterator.SEG_LINETO) {
                    final Point2D closest = closest(new Line2D.Double(lastX, lastY, coords[0], coords[1]), p);

                    if (p.distance(closest) <= strokeWidth() / 2) {
                        return true;
                    }
                }

                lastX = coords[0];
                lastY = coords[1];
                it.next();
            }

            return false;
        }

        private double strokeWidth() {
            final BasicStroke stroke = (BasicStroke) getContainer().getConnectionStroke();
            return stroke.getLineWidth();
        }

        @Override
        Type getType() {
            return Type.TURN_CONNECTION;
        }

        @Override
        int getZIndex() {
            return 0;
        }

        @Override
        boolean beginDrag(double x, double y) {
            dragBegin = new Point2D.Double(x, y);
            dragOffsetX = 0;
            dragOffsetY = 0;
            return true;
        }

        @Override
        State drag(double x, double y, InteractiveElement target, State old) {
            dragOffsetX = x - dragBegin.getX();
            dragOffsetY = y - dragBegin.getY();
            return old;
        }

        @Override
        State drop(double x, double y, InteractiveElement target, State old) {
            drag(x, y, target, old);

            if (isRemoveDragOffset()) {
                turn.remove();
            }

            dragBegin = null;
            dragOffsetX = 0;
            dragOffsetY = 0;
            return new State.Dirty(old);
        }

        private boolean isRemoveDragOffset() {
            final double r = getContainer().getGui(turn.getFrom().getRoad()).connectorRadius;
            final double max = r - strokeWidth() / 2;
            return hypot(dragOffsetX, dragOffsetY) > max;
        }
    }

    private static final class Corner {
        final double x1;
        final double y1;

        final double cx1;
        final double cy1;

        final double cx2;
        final double cy2;

        final double x2;
        final double y2;

        Corner(Point2D c1, Point2D cp1, Point2D cp2, Point2D c2) {
            this.x1 = c1.getX();
            this.y1 = c1.getY();
            this.cx1 = cp1.getX();
            this.cy1 = cp1.getY();
            this.cx2 = cp2.getX();
            this.cy2 = cp2.getY();
            this.x2 = c2.getX();
            this.y2 = c2.getY();
        }

        @Override
        public String toString() {
            return "Corner [x1=" + x1 + ", y1=" + y1 + ", cx1=" + cx1 + ", cy1=" + cy1 + ", cx2=" + cx2 + ", cy2=" + cy2
                + ", x2=" + x2 + ", y2=" + y2 + "]";
        }
    }

    private final class Linkage implements Comparable<Linkage> {
        final RoadGui roadGui;
        final Road.End roadEnd;
        final double angle;

        double lTrim;
        double rTrim;

        Linkage(Road.End roadEnd) {
            this.roadGui = getContainer().getGui(roadEnd.getRoad());
            this.roadEnd = roadEnd;
            this.angle = normalize(roadGui.getAngle(roadEnd) + PI);

            roads.put(angle, this);
        }

        @Override
        public int compareTo(Linkage o) {
            return Double.compare(angle, o.angle);
        }

        public void trimLeft(Linkage right) {
            right.trimRight(this);

            final Line2D leftCurb = roadGui.getLeftCurb(roadEnd);
            final Line2D rightCurb = right.roadGui.getRightCurb(right.roadEnd);

            final double leftAngle = angle(leftCurb);
            final double rightAngle = angle(rightCurb);

            final Point2D isect;
            if (abs(PI - normalize(rightAngle - leftAngle)) > PI / 12) {
                isect = intersection(leftCurb, rightCurb);
            } else {
                isect = GuiUtil.relativePoint(leftCurb.getP1(), roadGui.getWidth(roadEnd) / 2, angle);
            }

            if (Math.abs(leftAngle - angle(leftCurb.getP1(), isect)) < 0.1) {
                lTrim = leftCurb.getP1().distance(isect);
            }
        }

        private void trimRight(Linkage left) {
            final Line2D rightCurb = roadGui.getRightCurb(roadEnd);
            final Line2D leftCurb = left.roadGui.getLeftCurb(left.roadEnd);

            final double rightAngle = angle(rightCurb);
            final double leftAngle = angle(leftCurb);

            final Point2D isect;
            if (abs(PI - normalize(rightAngle - leftAngle)) > PI / 12) {
                isect = intersection(rightCurb, leftCurb);
            } else {
                isect = GuiUtil.relativePoint(rightCurb.getP1(), roadGui.getWidth(roadEnd) / 2, angle);
            }

            if (Math.abs(rightAngle - angle(rightCurb.getP1(), isect)) < 0.1) {
                rTrim = rightCurb.getP1().distance(isect);
            }
        }

        public void trimAdjust() {
            final double MAX_TAN = tan(PI / 2 - MAX_ANGLE);

            final double sin = roadGui.getWidth(roadEnd);
            final double cos = abs(lTrim - rTrim);
            final double tan = sin / cos;

            if (tan < MAX_TAN) {
                lTrim = max(lTrim, rTrim - sin / MAX_TAN);
                rTrim = max(rTrim, lTrim - sin / MAX_TAN);
            }

            lTrim += container.getLaneWidth() / 2;
            rTrim += container.getLaneWidth() / 2;
        }
    }

    // max angle between corners
    private static final double MAX_ANGLE = Math.toRadians(30);

    private final GuiContainer container;
    private final Junction junction;

    final double x;
    final double y;

    private final NavigableMap<Double, Linkage> roads = new TreeMap<>();

    private final Path2D area = new Path2D.Double();

    JunctionGui(GuiContainer container, Junction j) {
        this.container = container;
        this.junction = j;

        container.register(this);

        final Point2D loc = container.translateAndScale(loc(j.getNode()));
        this.x = loc.getX();
        this.y = loc.getY();

        final Set<Road> done = new HashSet<>();
        for (Road r : j.getRoads()) {
            if (!done.contains(r)) {
                done.add(r);

                if (r.getFromEnd().getJunction().equals(j)) {
                    new Linkage(r.getFromEnd());
                }
                if (r.getToEnd().getJunction().equals(j)) {
                    new Linkage(r.getToEnd());
                }
            }
        }

        recalculate();
    }

    void recalculate() {
        for (Linkage l : roads.values()) {
            l.lTrim = 0;
            l.rTrim = 0;
        }

        area.reset();
        if (roads.size() < 2) {
            return;
        }

        Linkage last = roads.lastEntry().getValue();
        for (Linkage l : roads.values()) {
            l.trimLeft(last);
            last = l;
        }
        for (Linkage l : roads.values()) {
            l.trimAdjust();
        }

        boolean first = true;
        for (Corner c : corners()) {
            if (first) {
                area.moveTo(c.x1, c.y1);
                first = false;
            } else {
                area.lineTo(c.x1, c.y1);
            }

            area.curveTo(c.cx1, c.cy1, c.cx2, c.cy2, c.x2, c.y2);
        }

        area.closePath();
    }

    private Iterable<Corner> corners() {
        final List<Corner> result = new ArrayList<>(roads.size());

        Linkage last = roads.lastEntry().getValue();
        for (Linkage l : roads.values()) {
            result.add(corner(last, l));
            last = l;
        }

        return result;
    }

    private Corner corner(Linkage right, Linkage left) {
        final Line2D rightCurb = right.roadGui.getRightCurb(right.roadEnd);
        final Line2D leftCurb = left.roadGui.getLeftCurb(left.roadEnd);

        final double rightAngle = angle(rightCurb);
        final double leftAngle = angle(leftCurb);

        final double delta = normalize(leftAngle - rightAngle);

        final boolean wide = delta > PI;
        final double a = wide ? max(0, delta - (PI + 2 * MAX_ANGLE)) : delta;

        final double cpf1 = cpf(a, container.getLaneWidth() / 5 + (wide ? right.roadGui.getWidth(right.roadEnd) : 0));
        final double cpf2 = cpf(a, container.getLaneWidth() / 5 + (wide ? left.roadGui.getWidth(left.roadEnd) : 0));

        final Point2D c1 = relativePoint(rightCurb.getP1(), cpf1, right.angle + PI);
        final Point2D c2 = relativePoint(leftCurb.getP1(), cpf2, left.angle + PI);

        return new Corner(rightCurb.getP1(), c1, c2, leftCurb.getP1());
    }

    public Set<RoadGui> getRoads() {
        final Set<RoadGui> result = new HashSet<>();

        for (Linkage l : roads.values()) {
            result.add(l.roadGui);
        }

        return Collections.unmodifiableSet(result);
    }

    double getLeftTrim(Road.End end) {
        return getLinkage(end).lTrim;
    }

    private Linkage getLinkage(Road.End end) {
        final double a = normalize(getContainer().getGui(end.getRoad()).getAngle(end) + PI);
        final Map.Entry<Double, Linkage> e = roads.floorEntry(a);
        return e != null ? e.getValue() : null;
    }

    double getRightTrim(Road.End end) {
        return getLinkage(end).rTrim;
    }

    Point2D getPoint() {
        return new Point2D.Double(x, y);
    }

    public GuiContainer getContainer() {
        return container;
    }

    public Junction getModel() {
        return junction;
    }

    public List<InteractiveElement> paint(Graphics2D g2d) {
        g2d.setColor(new Color(96, 96, 96));
        g2d.fill(area);

        final List<InteractiveElement> result = new ArrayList<>();

        if (getModel().isPrimary()) {
            for (Road.End r : new HashSet<>(getModel().getRoadEnds())) {
                for (Turn t : r.getTurns()) {
                    result.add(new TurnConnection(t));
                }
            }
        }

        return result;
    }

    public Rectangle2D getBounds() {
        return area.getBounds2D();
    }
}
