/* Copyright (C) 2005-2011 Fabio Riccardi */

package com.lightcrafts.ui.region;

import com.lightcrafts.model.Contour;
import com.lightcrafts.utils.xml.XmlDocument;
import com.lightcrafts.utils.xml.XMLException;
import com.lightcrafts.utils.xml.XmlNode;

import static com.lightcrafts.ui.region.Locale.LOCALE;

import javax.swing.event.UndoableEditListener;
import javax.swing.undo.AbstractUndoableEdit;
import javax.swing.undo.CannotRedoException;
import javax.swing.undo.CannotUndoException;
import javax.swing.undo.UndoableEditSupport;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.*;

/**
 * A RegionModel maintains a collection of Curves that define various
 * Regions which may appear in the overlays.  The Regions are constructed by
 * unioning subsets of the Curves, and each subset has an associated
 * CurveComponent to render it.
 * <p>
 * This model can tell which Curves are rendered in a given CurveComponent,
 * and which CurveComponents render a given Curve.
 * <p>
 * The RegionModel maintains a Contour instance for each Curve.  Each
 * instance is replaced every time its Curve changes.  This is for building
 * Region implementations using ContourRegion.
 * <p>
 * In addition to Curves, Contours, and CurveComponents, this model also
 * keeps track of the current CurveSelection, which is a Collection of Curves
 * that are drawn highlighted to respond to copy and paste; and sometimes
 * also a single "editing" Curve, which is drawn with its control points
 * visible and with an extra layer of edit batching for listeners.
 * <p>
 * Every Curve may have an associated ClonePoint, to accomodate the Curves
 * used by the clone tool.
 * <p>
 * There is also one RegionMode per RegionModel, which determines the policies
 * for mouse event handling on CurveComponents, translating MouseEvents into
 * edits on Curve data.
 */

class RegionModel {

    // Learn about changes to the RegionModel that are only broadcast
    // inside this package.
    interface Listener {

        // When the editing Curve changes, find out what it was before
        // and what it is now.  Either may be null, but not both.
        void editingCurveChanged(Curve oldCurve, Curve newCurve);

        // After an undo or redo happens, this tells what the new RegionMode
        // should be.
        void modeChanged(RegionMode oldMode, RegionMode newMode);
    }

    // Switch on some printlines that show about edit batching and
    // change notification:
    private static final boolean Debug =
        System.getProperty("lightcrafts.debug.regions") != null;

    private LinkedList listeners;   // RegionModel.Listeners

    // Left values are Curves, and right values are CurveComponents:
    private TwoWayMultiMap map;

    private RegionOverlay overlay;  // Forwards notifications to users

    private Map contours;   // Curves to Contour Map, for Region impl

    private CurveFactory factory;   // Instantiates all Curves

    private CurveSelection selection;   // The selection model for Curves

    private Curve editingCurve;     // See intro comments, maybe null

    // Handle mouse events on CurveComponents:
    private MajorRegionMode majorMode;
    private MinorRegionMode minorMode;

    private UndoableEditSupport undoSupport;

    private RegionEdit currentEdit;

    private int batchEdit;          // Suppress undoSupport while positive

    private boolean isUndoing;      // Suppress undoSupport while undoing

    private boolean isRestoring;    // Suppress undoSupport while restoring

    private int batchNotify;        // Count notification depth, for dev.

    RegionModel(RegionOverlay overlay) {
        this.overlay = overlay;
        map = new TwoWayMultiMap();
        contours = new HashMap();
        factory = new CurveFactory();
        selection = new CurveSelection();
        undoSupport = new UndoableEditSupport();
        listeners = new LinkedList();
    }

    CurveSelection getSelection() {
        return selection;
    }

    void setEditingCurve(Curve curve) {
        if (editingCurve == curve) {
            return;
        }
        Curve oldCurve = editingCurve;
        Curve newCurve = curve;

        if (curve != null) {
            if (editingCurve != null) {
                setEditingCurve(null);
            }
            editingCurve = curve;
            selection.addCurve(editingCurve);
            notifyChangeStart(curve);
        }
        else {
            selection.removeCurve(editingCurve);
            notifyChangeEnd(editingCurve);
            editingCurve = null;
        }
        notifyListenersEditCurveChanged(oldCurve, newCurve);
    }

    boolean isEditingCurve(Curve curve) {
        return curve == editingCurve;
    }

    private RegionMode getMode() {
        return (minorMode != null) ? minorMode : majorMode;
    }

    void setMinorMode(MinorRegionMode newMode) {
        if (newMode == minorMode) {
            return;
        }
        RegionMode oldMode = getMode();
        if (oldMode == majorMode) {
            majorMode.modeExited();
        }
        minorMode = newMode;
        notifyModeChanged(oldMode, newMode);
    }

    void setMajorMode(MajorRegionMode newMode) {
        if (newMode == majorMode) {
            return;
        }
        RegionMode oldMode = getMode();
        if ((oldMode == majorMode) && (majorMode != null)) {
            majorMode.modeExited();
        }
        majorMode = newMode;
        minorMode = null;
        if (oldMode == null) {
            // If this is the first MajorRegionMode, kick off undoable edits.
            currentEdit = new RegionEdit();
        }
        newMode.modeEntered();
        setEditingCurve(newMode.getEditingCurve());
        notifyModeChanged(oldMode, newMode);
    }

    void setMajorModeWithoutExitOrEnter(MajorRegionMode newMode) {
        if (newMode == majorMode) {
            return;
        }
        RegionMode oldMode = getMode();
        majorMode = newMode;
        minorMode = null;
        setEditingCurve(newMode.getEditingCurve());
        notifyModeChanged(oldMode, newMode);
    }

    Set getComponents(Curve curve) {
        return map.getRight(curve);
    }

    Set getCurves(CurveComponent comp) {
        return map.getLeft(comp);
    }

    Set getAllComponents() {
        return map.getAllRight();
    }

    Set getAllCurves() {
        return map.getAllLeft();
    }

    void editStart() {
        batchEdit++;
        dumpEdit("edit start");
    }

    void editEnd() {
        dumpEdit("edit end");
        if (--batchEdit == 0) {
            postEdit();
        }
    }

    void editCancel() {
        dumpEdit("edit cancel");
        --batchEdit;
        // Don't call postEdit().
    }

    // Useful debug dump, to track timing and balancing of edit batches:
    private void dumpEdit(String s) {
        if (! Debug) {
            return;
        }
        System.out.print("|");
        for (int n=0; n<batchEdit;n++) {
            System.out.print("  ");
        }
        System.out.print(batchEdit);
        System.out.print(" ");
        System.out.println(s);
    }

    // Useful debug dump, to track timing and balancing of change notifications:
    private void dumpNotify(String s) {
        if (! Debug) {
            return;
        }
        System.out.print("|");
        for (int n=0; n<batchNotify;n++) {
            System.out.print("  ");
        }
        System.out.print(batchNotify);
        System.out.print(" ");
        System.out.println(s);
    }

    private void dumpModeChange(RegionMode oldMode, RegionMode newMode) {
        if (Debug) {
            System.out.println(
                "mode change: " +
                getNameOfMode(oldMode) + " -> " + getNameOfMode(newMode)
            );
        }
    }

    private static String getNameOfMode(RegionMode mode) {
        if (mode != null) {
            return mode.getClass().getName().replaceAll(".*\\.", "");
        }
        return "null";
    }

    void notifyChangeStart(Curve curve) {
        Set comps = map.getRight(curve);
        overlay.regionBatchStart(comps);
        batchNotify++;
        dumpNotify("change start");
    }

    void notifyChanged(Curve curve) {
        changeContour(curve);
        Set comps = map.getRight(curve);
        overlay.regionChanged(comps, curve);
        if (batchEdit == 0) {
            postEdit();
        }
    }

    void notifyTranslated(Curve curve, double dx, double dy) {
        translateContour(curve, dx, dy);
        Set comps = map.getRight(curve);
        overlay.regionChanged(comps, curve);
        if (batchEdit == 0) {
            postEdit();
        }
    }

    void notifyChangeEnd(Curve curve) {
        Set comps = map.getRight(curve);
        overlay.regionsBatchEnd(comps);
        dumpNotify("change end");
        batchNotify--;
    }

    void setNewCurveType(int type) {
        factory.setCurveType(type);
    }

    int getNewCurveType() {
        return factory.getCurveType();
    }

    Curve createCurve(CurveComponent comp) {
        Curve curve = factory.createCurve();
        addCurve(comp, curve);
        return curve;
    }

    Curve createCurve(CurveComponent comp, Point2D p) {
        Curve curve = factory.createCurve();
        Point2D q = new Point2D.Double(p.getX() - 20, p.getY() - 20);
        curve.setClonePoint(q);
        addCurve(comp, curve);
        return curve;
    }

    Curve createSpotCurve(CurveComponent comp, Point2D p) {
        Curve curve = factory.createSpotCurve(p);
        addCurve(comp, curve);
        return curve;
    }

    void addCurve(CurveComponent comp, Curve curve) {
        map.put(curve, comp);
        addContour(curve);
        // Maybe this CurveComponent shows clone points, but this Curve has
        // never had its clone point initialized.
        if (comp.showsClonePoints() && (curve.getClonePoint() == null)) {
            Rectangle2D bounds = curve.getBounds2D();
            Point2D center = new Point2D.Double(
                bounds.getCenterX(), bounds.getCenterY()
            );
            curve.setClonePoint(center);
            changeContour(curve);
        }
        Collection comps = Collections.singleton(comp);
        overlay.regionChanged(comps, curve);
        if (batchEdit == 0) {
            postEdit();
        }
    }

    void removeCurve(CurveComponent comp, Curve curve) {
        if (selection.isSelected(curve)) {
            selection.removeCurve(curve);
        }
        if (isEditingCurve(curve)) {
            setEditingCurve(null);
        }
        map.remove(curve, comp);

        Collection comps = Collections.singleton(comp);
        overlay.regionChanged(comps, curve);
        if (batchEdit == 0) {
            postEdit();
        }
    }

    void removeCurve(Curve curve) {
        // Don't just call map.removeLeft(curve), because we need to fire
        // all the notifications:
        Set comps = getComponents(curve);
        batchEdit++;
        for (Iterator i=comps.iterator(); i.hasNext(); ) {
            CurveComponent comp = (CurveComponent) i.next();
            removeCurve(comp, curve);
        }
        map.removeLeft(curve);
        removeContour(curve);
        batchEdit--;
        if (batchEdit == 0) {
            postEdit();
        }
    }

    void removeCurves() {
        Set curves = getAllCurves();
        batchEdit++;
        for (Iterator i=curves.iterator(); i.hasNext(); ) {
            Curve curve = (Curve) i.next();
            removeCurve(curve);
        }
        batchEdit--;
        if (batchEdit == 0) {
            postEdit();
        }
    }

    void addComponent(CurveComponent comp) {
        map.addRight(comp);
    }

    void removeComponent(CurveComponent comp) {
        map.removeRight(comp);
    }

    Contour getContour(Curve curve) {
        return (Contour) contours.get(curve);
    }

    void setClonePoint(Curve curve, Point2D p) {
        curve.setClonePoint(p);
    }

    Point2D getClonePoint(Curve curve) {
        return curve.getClonePoint();
    }

    boolean hasClonePoint(Curve curve) {
        return getClonePoint(curve) != null;
    }

    private void addContour(Curve curve) {
        if (! contours.containsKey(curve)) {
            Contour contour = new CurveContour(curve);
            contours.put(curve, contour);
        }
    }

    private void changeContour(Curve curve) {
        Contour contour = new CurveContour(curve);
        contours.put(curve, contour);
    }

    private void translateContour(Curve curve, double dx, double dy) {
        CurveContour contour = (CurveContour) contours.get(curve);
        contour.addTranslation(new Point2D.Double(dx, dy));
    }

    private void removeContour(Curve curve) {
        contours.remove(curve);
    }

    void addListener(Listener listener) {
        listeners.add(listener);
    }

    void removeListener(Listener listener) {
        listeners.remove(listener);
    }

    void notifyListenersEditCurveChanged(Curve oldCurve, Curve newCurve) {
        for (Iterator i=listeners.iterator(); i.hasNext(); ) {
            Listener listener = (Listener) i.next();
            listener.editingCurveChanged(oldCurve, newCurve);
        }
    }

    void notifyModeChanged(RegionMode oldMode, RegionMode newMode) {
        dumpModeChange(oldMode, newMode);
        for (Iterator i=listeners.iterator(); i.hasNext(); ) {
            Listener listener = (Listener) i.next();
            listener.modeChanged(oldMode, newMode);
        }
    }

    void addUndoableEditListener(UndoableEditListener listener) {
        undoSupport.addUndoableEditListener(listener);
    }

    void removeUndoableEditListener(UndoableEditListener listener) {
        undoSupport.removeUndoableEditListener(listener);
    }

    private void postEdit() {
        if (isRestoring || isUndoing) {
            return;
        }
        if (Debug) {
            System.out.println("post edit");
        }
        currentEdit.end();
        undoSupport.postEdit(currentEdit);
        currentEdit = new RegionEdit();
    }

    // All Curve references need to be reset after undo and redo, because
    // undo and redo reinstantiate all the Curves.
    // 
    // Call this method to identify correspondences between stale Curves
    // and fresh ones.

    Curve getRestoredCurve(Curve old) {
        Set curves = getAllCurves();
        for (Iterator i=curves.iterator(); i.hasNext(); ) {
            Curve curve = (Curve) i.next();
            if (curve.matches(old)) {
                return curve;
            }
        }
        return null;
    }

    // A state preserve/restore edit, like StateEdit but with XmlDocuments
    // instead of Hashtables.

    private class RegionEdit extends AbstractUndoableEdit {

        private final static String ModeTag = "mode";

        private XmlDocument beforeDoc = new XmlDocument("before");
        private XmlDocument afterDoc = new XmlDocument("after");

        private List beforeComps = new LinkedList();
        private List afterComps = new LinkedList();

        private MajorRegionMode beforeMode;
        private MajorRegionMode afterMode;

        private RegionEdit() {
            beforeComps = getCompsList();
            beforeMode = majorMode;
            XmlNode root = beforeDoc.getRoot();
            save(beforeComps, root);
            XmlNode modeNode = root.addChild(ModeTag);
            beforeMode.save(modeNode);
        }

        public void end() {
            afterComps = getCompsList();
            afterMode = majorMode;
            XmlNode root = afterDoc.getRoot();
            save(afterComps, root);
            XmlNode modeNode = root.addChild(ModeTag);
            afterMode.save(modeNode);
        }

        public String getPresentationName() {
            return LOCALE.get("RegionChangeEditName");
        }

        public void undo() {
            super.undo();
            if (minorMode != null) {
                throw new CannotUndoException();
            }
            try {
                restoreWithMode(beforeDoc, beforeComps, beforeMode);
                currentEdit = new RegionEdit();
            }
            catch (XMLException e) {
                CannotUndoException cue = new CannotUndoException();
                cue.initCause(e);
                throw cue;
            }
        }

        public void redo() {
            super.redo();
            if (minorMode != null) {
                throw new CannotRedoException();
            }
            try {
                restoreWithMode(afterDoc, afterComps, afterMode);
                currentEdit = new RegionEdit();
            }
            catch (XMLException e) {
                CannotRedoException cre = new CannotRedoException();
                cre.initCause(e);
                throw cre;
            }
        }

        // Make a List out of the CurveComponent Set, so that the save/restore
        // cycle will be deterministic:
        private List getCompsList() {
            Set comps = getAllComponents();
            List list = new LinkedList();
            for (Iterator i=comps.iterator(); i.hasNext(); ) {
                list.add(i.next());
            }
            return list;
        }

        // We have our own version of setMinorMode() in RegionEdit, because
        // when there is undo/redo going on, the order of operations must be:
        // 1) old mode runs modeExited(); 2) restore occurs; 3) new mode
        // runs modeEntered().
        private void restoreWithMode(
            XmlDocument doc, List comps, MajorRegionMode newMode
        ) throws XMLException {
            isUndoing = true;

            XmlNode root = doc.getRoot();

            MajorRegionMode oldMode = majorMode;
            oldMode.modeExited();

            restore(comps, root);

            XmlNode modeNode = root.getChild(ModeTag);
            newMode.restore(modeNode);

            majorMode = newMode;

            setEditingCurve(newMode.getEditingCurve());
            newMode.modeEntered();

            isUndoing = false;

            notifyModeChanged(oldMode, newMode);
        }
    }

    private final static String ComponentTag = "Region";
    private final static String CurveTag = "Contour";
    private final static String ReferenceTag = "Reference";
    private final static String IndexTag = "Index";

    // This save method preserves Curve information, but it saves nothing
    // about CurveComponents except their associations with Curves:

    void save(List comps, XmlNode node) {
        Set curves = getAllCurves();
        Map curveNumbers = new HashMap();   // CubicCurves to Integers
        int count = 0;
        for (Iterator i=curves.iterator(); i.hasNext(); ) {
            Curve curve = (Curve) i.next();
            XmlNode curveNode = node.addChild(CurveTag);
            CurveFactory.save(curve, curveNode);
            curveNumbers.put(curve, count++);
        }
        for (Iterator i=comps.iterator(); i.hasNext(); ) {
            CurveComponent comp = (CurveComponent) i.next();
            XmlNode compNode = node.addChild(ComponentTag);
            Set compCurves = getCurves(comp);
            for (Iterator j=compCurves.iterator(); j.hasNext(); ) {
                Curve compCurve = (Curve) j.next();
                Integer index = (Integer) curveNumbers.get(compCurve);
                XmlNode refNode = compNode.addChild(ReferenceTag);
                refNode.setAttribute(IndexTag, index.toString());
            }
        }
    }

    // This restore method assumes that the CurveComponents of this model
    // have already been consistently initialized by addComponent():

    void restore(List comps, XmlNode node) throws XMLException {
        isRestoring = true;
        Map<Integer, Curve> curveNumbers = new HashMap<Integer, Curve>();
        int count = 0;
        XmlNode[] curveNodes = node.getChildren(CurveTag);
        for (XmlNode curveNode : curveNodes) {
            Curve curve = CurveFactory.restore(curveNode);
            curve.restore(curveNode);
            curveNumbers.put(count++, curve);
        }
        XmlNode[] compNodes = node.getChildren(ComponentTag);
        if (compNodes.length != comps.size()) {
            throw new XMLException(
                "The number of regions doesn't match the number of tools"
            );
        }
        removeCurves();
        Iterator compIter = comps.iterator();
        for (XmlNode compNode : compNodes) {
            CurveComponent comp = (CurveComponent) compIter.next();
            XmlNode[] refNodes = compNode.getChildren(ReferenceTag);
            for (XmlNode refNode : refNodes) {
                String attr = refNode.getAttribute(IndexTag);
                Integer index;
                try {
                    index = Integer.valueOf(attr);
                }
                catch (NumberFormatException e) {
                    throw new XMLException("Not a number: " + attr, e);
                }
                Curve curve = curveNumbers.get(index);
                if (curve == null) {
                    throw new XMLException(
                        "Curve index out of bounds: " + attr
                    );
                }
                addCurve(comp, curve);
            }
        }
        isRestoring = false;
    }

    // This works just like restore() above, except it doesn't clear out
    // the preexisting model state.

    void addSaved(List comps, XmlNode node) throws XMLException {
        isRestoring = true;
        Map<Integer, Curve> curveNumbers = new HashMap<Integer, Curve>();
        int count = 0;
        XmlNode[] curveNodes = node.getChildren(CurveTag);
        for (XmlNode curveNode : curveNodes) {
            Curve curve = CurveFactory.restore(curveNode);
            curve.restore(curveNode);
            curveNumbers.put(count++, curve);
        }
        XmlNode[] compNodes = node.getChildren(ComponentTag);
        if (compNodes.length != comps.size()) {
            throw new XMLException(
                "The number of regions doesn't match the number of tools"
            );
        }
        Iterator compIter = comps.iterator();
        for (XmlNode compNode : compNodes) {
            CurveComponent comp = (CurveComponent) compIter.next();
            XmlNode[] refNodes = compNode.getChildren(ReferenceTag);
            for (XmlNode refNode : refNodes) {
                String attr = refNode.getAttribute(IndexTag);
                Integer index;
                try {
                    index = Integer.valueOf(attr);
                }
                catch (NumberFormatException e) {
                    throw new XMLException("Expected a number: " + attr, e);
                }
                Curve curve = curveNumbers.get(index);
                if (curve == null) {
                    throw new XMLException("Not a valid curve index: " + attr);
                }
                addCurve(comp, curve);
            }
        }
        isRestoring = false;
    }
}
