// License: GPL. For details, see LICENSE file.
package relcontext.actions;

import static org.openstreetmap.josm.tools.I18n.tr;

import java.awt.geom.Area;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;

import org.openstreetmap.josm.command.AddCommand;
import org.openstreetmap.josm.command.ChangeCommand;
import org.openstreetmap.josm.command.Command;
import org.openstreetmap.josm.command.SequenceCommand;
import org.openstreetmap.josm.data.UndoRedoHandler;
import org.openstreetmap.josm.data.coor.EastNorth;
import org.openstreetmap.josm.data.osm.DataSet;
import org.openstreetmap.josm.data.osm.DefaultNameFormatter;
import org.openstreetmap.josm.data.osm.INode;
import org.openstreetmap.josm.data.osm.Node;
import org.openstreetmap.josm.data.osm.OsmPrimitive;
import org.openstreetmap.josm.data.osm.Relation;
import org.openstreetmap.josm.data.osm.RelationMember;
import org.openstreetmap.josm.data.osm.Way;
import org.openstreetmap.josm.gui.MainApplication;
import org.openstreetmap.josm.spi.preferences.Config;
import org.openstreetmap.josm.tools.Geometry;
import org.openstreetmap.josm.tools.Geometry.PolygonIntersection;
import org.openstreetmap.josm.tools.Utils;

/**
 *
 * @author Zverik
 */
public final class SplittingMultipolygons {
    private static final String PREF_MULTIPOLY = "reltoolbox.multipolygon.";

    private SplittingMultipolygons() {
        // Hide default constructor for utilities classes
    }

    public static boolean canProcess(Collection<Way> ways) {
        List<Way> rings = new ArrayList<>();
        List<Way> arcs = new ArrayList<>();
        Area a = MainApplication.getLayerManager().getEditDataSet().getDataSourceArea();
        for (Way way : ways) {
            if (way.isDeleted())
                return false;
            for (Node n : way.getNodes()) {
                if (n.isIncomplete() || (a != null && !a.contains(n.lon(), n.lat())))
                    return false;
            }
            if (way.isClosed()) {
                rings.add(way);
            } else {
                arcs.add(way);
            }
        }

        // If there are more that one segment, check that they touch rings
        if (arcs.size() > 1) {
            for (Way segment : arcs) {
                boolean found = false;
                for (Way ring : rings) {
                    if (ring.containsNode(segment.firstNode()) && ring.containsNode(segment.lastNode())) {
                        found = true;
                    }
                }
                if (!found)
                    return false;
            }
        }

        if (rings.isEmpty() && arcs.isEmpty())
            return false;

        // check for non-containment of rings
        for (int i = 0; i < rings.size() - 1; i++) {
            for (int j = i + 1; j < rings.size(); j++) {
                PolygonIntersection intersection = Geometry.polygonIntersection(rings.get(i).getNodes(), rings.get(j).getNodes());
                if (intersection == PolygonIntersection.FIRST_INSIDE_SECOND || intersection == PolygonIntersection.SECOND_INSIDE_FIRST)
                    return false;
            }
        }

        return true;
    }

    public static List<Relation> process(Collection<Way> selectedWays) {
        List<Relation> result = new ArrayList<>();
        List<Way> rings = new ArrayList<>();
        List<Way> arcs = new ArrayList<>();
        for (Way way : selectedWays) {
            if (way.isClosed()) {
                rings.add(way);
            } else {
                arcs.add(way);
            }
        }

        for (Way ring : rings) {
            List<Command> commands = new ArrayList<>();
            Relation newRelation = SplittingMultipolygons.attachRingToNeighbours(ring, commands);
            if (newRelation != null && !commands.isEmpty()) {
                UndoRedoHandler.getInstance().add(commands.get(0));
                result.add(newRelation);
            }
        }

        for (Way arc : arcs) {
            List<Command> commands = new ArrayList<>();
            Relation newRelation = SplittingMultipolygons.tryToCloseOneWay(arc, commands);
            if (newRelation != null && !commands.isEmpty()) {
                UndoRedoHandler.getInstance().add(commands.get(0));
                result.add(newRelation);
            }
        }
        return result;
    }

    /**
     * Appends "append" to "base" so the closed polygon forms.
     */
    private static void closePolygon(List<Node> base, List<Node> append) {
        if (append.get(0).equals(base.get(0)) && append.get(append.size() - 1).equals(base.get(base.size() - 1))) {
            List<Node> ap2 = new ArrayList<>(append);
            Collections.reverse(ap2);
            append = ap2;
        }
        base.remove(base.size() - 1);
        base.addAll(append);
    }

    /**
     * Checks if a middle point between two nodes is inside a polygon. Useful to check if the way is inside.
     */
    private static boolean segmentInsidePolygon(Node n1, Node n2, List<Node> polygon) {
        EastNorth en1 = n1.getEastNorth();
        EastNorth en2 = n2.getEastNorth();
        Node testNode = new Node(new EastNorth((en1.east() + en2.east()) / 2.0, (en1.north() + en2.north()) / 2.0));
        return Geometry.nodeInsidePolygon(testNode, polygon);
    }

    /**
     * Splits a way with regard to containing relations. This modifies the way and the relation, be prepared.
     * @param w The way.
     * @param n1 The first node to split at.
     * @param n2 The second node to split at.
     * @param commands List of commands to add way/relation changing to. If null, never mind.
     * @return Newly created ways. <b>Warning:</b> if commands is no not, newWays contains {@code w}.
     */
    public static List<Way> splitWay(Way w, Node n1, Node n2, List<Command> commands) {
        List<Node> nodes = new ArrayList<>(w.getNodes());
        if (w.isClosed()) {
            nodes.remove(nodes.size() - 1);
        }
        int index1 = nodes.indexOf(n1);
        int index2 = n2 == null ? -1 : nodes.indexOf(n2);
        if (index1 > index2) {
            int tmp = index1;
            index1 = index2;
            index2 = tmp;
        }
        // right now index2 >= index1
        if (index2 < 1 || index1 >= w.getNodesCount() - 1 || index2 >= w.getNodesCount())
            return Collections.emptyList();
        if (w.isClosed() && (index1 < 0 || index1 == index2 || index1 + w.getNodesCount() == index2))
            return Collections.emptyList();

        // todo: download parent relations!

        // make a list of segments
        List<List<Node>> chunks = new ArrayList<>(2);
        List<Node> chunk = new ArrayList<>();
        for (int i = 0; i < nodes.size(); i++) {
            chunk.add(nodes.get(i));
            if ((w.isClosed() || chunk.size() > 1) && (i == index1 || i == index2)) {
                chunks.add(chunk);
                chunk = new ArrayList<>();
                chunk.add(nodes.get(i));
            }
        }
        chunks.add(chunk);

        // for closed way ignore the way boundary
        if (w.isClosed()) {
            chunks.get(chunks.size() - 1).addAll(chunks.get(0));
            chunks.remove(0);
        } else if (chunks.get(chunks.size() - 1).size() < 2) {
            chunks.remove(chunks.size() - 1);
        }

        // build a map of referencing relations
        Map<Relation, Integer> references = new HashMap<>();
        List<Command> relationCommands = new ArrayList<>();
        for (OsmPrimitive p : w.getReferrers()) {
            if (p instanceof Relation) {
                Relation rel = commands == null ? (Relation) p : new Relation((Relation) p);
                if (commands != null) {
                    relationCommands.add(new ChangeCommand(p, rel));
                }
                for (int i = 0; i < rel.getMembersCount(); i++) {
                    if (rel.getMember(i).getMember().equals(w)) {
                        references.put(rel, i);
                    }
                }
            }
        }

        // build ways
        List<Way> result = new ArrayList<>();
        Way updatedWay = commands == null ? w : new Way(w);
        updatedWay.setNodes(chunks.get(0));
        if (commands != null) {
            commands.add(new ChangeCommand(w, updatedWay));
            result.add(updatedWay);
        }

        DataSet ds = MainApplication.getLayerManager().getEditDataSet();
        for (int i = 1; i < chunks.size(); i++) {
            List<Node> achunk = chunks.get(i);
            Way newWay = new Way();
            newWay.setKeys(w.getKeys());
            result.add(newWay);
            for (Relation rel : references.keySet()) {
                int relIndex = references.get(rel);
                rel.addMember(relIndex + 1, new RelationMember(rel.getMember(relIndex).getRole(), newWay));
            }
            newWay.setNodes(achunk);
            if (commands != null) {
                commands.add(new AddCommand(ds, newWay));
            }
        }
        if (commands != null) {
            commands.addAll(relationCommands);
        }
        return result;
    }

    public static List<Way> splitWay(Way w, Node n1, Node n2) {
        return splitWay(w, n1, n2, null);
    }

    /**
     * Find a way the tips of a segment, ensure it's in a multipolygon and try to close the relation.
     */
    public static Relation tryToCloseOneWay(Way segment, List<Command> resultingCommands) {
        if (segment.isClosed() || segment.isIncomplete())
            return null;

        final Node segmentFirstNode = segment.firstNode();
        final Node segmentLastNode = segment.lastNode();

        if (segmentLastNode == null || segmentFirstNode == null) {
            return null;
        }

        List<Way> ways = intersection(
                Utils.filteredCollection(segmentFirstNode.getReferrers(), Way.class),
                Utils.filteredCollection(segmentLastNode.getReferrers(), Way.class));
        ways.remove(segment);
        for (Iterator<Way> iter = ways.iterator(); iter.hasNext();) {
            boolean save = false;
            for (OsmPrimitive ref : iter.next().getReferrers()) {
                if (ref instanceof Relation && ref.isMultipolygon() && !ref.isDeleted()) {
                    save = true;
                }
            }
            if (!save) {
                iter.remove();
            }
        }
        if (ways.isEmpty())
            return null; // well...
        Way target = ways.get(0);

        // time to create a new multipolygon relation and a command stack
        List<Command> commands = new ArrayList<>();
        Relation newRelation = new Relation();
        newRelation.put("type", "multipolygon");
        newRelation.addMember(new RelationMember("outer", segment));
        Collection<String> linearTags = Config.getPref().getList(PREF_MULTIPOLY + "lineartags", CreateMultipolygonAction.DEFAULT_LINEAR_TAGS);
        Way segmentCopy = new Way(segment);
        boolean changed = false;
        for (String key : segmentCopy.keySet()) {
            if (!linearTags.contains(key)) {
                newRelation.put(key, segmentCopy.get(key));
                segmentCopy.remove(key);
                changed = true;
            }
        }
        if (changed) {
            commands.add(new ChangeCommand(segment, segmentCopy));
        }

        // now split the way, at last
        List<Way> newWays = new ArrayList<>(splitWay(target, segmentFirstNode, segmentLastNode, commands));

        Way addingWay = null;
        if (target.isClosed()) {
            Way utarget = newWays.get(1);
            Way alternate = newWays.get(0);
            List<Node> testRing = new ArrayList<>(segment.getNodes());
            closePolygon(testRing, utarget.getNodes());
            addingWay = segmentInsidePolygon(alternate.getNode(0), alternate.getNode(1), testRing) ? alternate : utarget;
        } else {
            for (Way w : newWays) {
                final INode wFirstNode = w.firstNode();
                final INode wLastNode = w.lastNode();
                if ((Objects.equals(wFirstNode, segmentFirstNode) && Objects.equals(wLastNode, segmentLastNode))
                        || (Objects.equals(wFirstNode, segmentLastNode) && Objects.equals(wLastNode, segmentFirstNode))) {
                    addingWay = w;
                    break;
                }
            }
        }
        newRelation.addMember(new RelationMember("outer", addingWay.getUniqueId() == target.getUniqueId() ? target : addingWay));
        commands.add(new AddCommand(MainApplication.getLayerManager().getEditDataSet(), newRelation));
        resultingCommands.add(new SequenceCommand(tr("Complete multipolygon for way {0}",
                DefaultNameFormatter.getInstance().format(segment)), commands));
        return newRelation;
    }

    /**
     * Returns all elements from {@code list1} that are in {@code list2}.
     */
    private static <T> List<T> intersection(Collection<T> list1, Collection<T> list2) {
        List<T> result = new ArrayList<>();
        for (T item : list1) {
            if (list2.contains(item)) {
                result.add(item);
            }
        }
        return result;
    }

    /**
     * Make a multipolygon out of the ring, but split it to attach to neighboring multipolygons.
     */
    public static Relation attachRingToNeighbours(Way ring, List<Command> resultingCommands) {
        if (!ring.isClosed() || ring.isIncomplete())
            return null;
        Map<Way, Boolean> touchingWays = new HashMap<>();
        for (Node n : ring.getNodes()) {
            for (OsmPrimitive p : n.getReferrers()) {
                if (p instanceof Way && !p.equals(ring)) {
                    for (OsmPrimitive r : p.getReferrers()) {
                        if (r instanceof Relation && "multipolygon".equals(r.get("type"))) {
                            if (touchingWays.containsKey(p)) {
                                touchingWays.put((Way) p, Boolean.TRUE);
                            } else {
                                touchingWays.put((Way) p, Boolean.FALSE);
                            }
                            break;
                        }
                    }
                }
            }
        }

        List<TheRing> otherWays = new ArrayList<>();
        for (Entry<Way, Boolean> e : touchingWays.entrySet()) {
            if (Boolean.TRUE.equals(e.getValue())) {
                otherWays.add(new TheRing(e.getKey()));
            }
        }

        // now touchingWays has only ways that touch the ring twice
        List<Command> commands = new ArrayList<>();
        TheRing theRing = new TheRing(ring); // this is actually useful

        for (TheRing otherRing : otherWays) {
            theRing.collide(otherRing);
        }

        theRing.putSourceWayFirst();
        for (TheRing otherRing : otherWays) {
            otherRing.putSourceWayFirst();
        }

        Map<Relation, Relation> relationCache = new HashMap<>();
        for (TheRing otherRing : otherWays) {
            commands.addAll(otherRing.getCommands(false, relationCache));
        }
        commands.addAll(theRing.getCommands(relationCache));
        TheRing.updateCommandsWithRelations(commands, relationCache);
        resultingCommands.add(new SequenceCommand(tr("Complete multipolygon for way {0}",
                DefaultNameFormatter.getInstance().format(ring)), commands));
        return theRing.getRelation();
    }
}
