package com.srbenoit.filter;

import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Composite;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import com.srbenoit.log.LoggedPanel;
import com.srbenoit.ui.UIUtilities;

/**
 * A panel on which a user can drag around filters so they connect into a graph.
 */
public class FilterTreePanel extends LoggedPanel implements KeyListener, MouseListener,
    MouseMotionListener {

    /** a version number for serialization */
    private static final long serialVersionUID = -8366059288608371787L;

    /** spacing between the filters in the list */
    private final static Object[] OBJECT_0;

    /** alpha composite object for rendering drag */
    private static final AlphaComposite ALPHA;

    /** distance between filters in the list and edge of panel */
    private final static int LIST_BORDER = 7;

    /** spacing between the filters in the list */
    private final static int LIST_SPACING = 7;

    /** the list of filters to show */
    private final List<AbstractFilter> listFilters;

    /** object on which to synchronize access to member variables */
    private final transient Object synch;

    /** the filter tree */
    private FilterTree tree;

    /** the filter on which a drag was started */
    private transient AbstractFilter dragFilter;

    /** flag to indicate we're dragging a filter around */
    private transient boolean isDragging;

    /** the point where dragging from the list was started */
    private transient Point dragStart;

    /** the width of the list */
    private final transient int listWidth;

    /** the width of the list */
    private transient boolean dirty;

    static {
        OBJECT_0 = new Object[0];
        ALPHA = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.7f);
    }

    /**
     * Constructs a new <code>FilterGraphPanel</code>. Call this method from within the AWT event
     * thread.
     *
     * @param  theFilters  the list of filters to show
     * @param  filterTree  the filter tree this panel will render
     */
    @SuppressWarnings("LeakingThisInConstructor")
    public FilterTreePanel(final List<Class<? extends AbstractFilter>> theFilters,
        final FilterTree filterTree) {

        super(null);

        Font font;
        Graphics2D grx;
        Constructor<? extends AbstractFilter> cons;
        AbstractFilter filter;
        FilterRenderer renderer;
        Rectangle bounds;
        int maxWidth;
        int yPos;
        int xCenter;

        if (theFilters == null) {
            throw new IllegalArgumentException("Filter class list may not be null");
        }

        if (filterTree == null) {
            throw new IllegalArgumentException("Filter tree may not be null");
        }

        this.synch = new Object();
        this.listFilters = new ArrayList<AbstractFilter>(theFilters.size());
        this.tree = filterTree;

        setFocusable(true);

        // Determine the width of each filter's box
        font = new Font("Dialog", Font.PLAIN, 12);
        setFont(font);
        grx = UIUtilities.getGraphics();
        grx.setFont(font);
        maxWidth = 0;

        for (Class<? extends AbstractFilter> clazz : theFilters) {

            cons = AbstractFilter.getNoArgConstructor(clazz);

            if (cons == null) {
                throw new IllegalArgumentException("Unable to get no-argument constructor for "
                    + clazz.getName());
            }

            try {
                filter = cons.newInstance(OBJECT_0);
            } catch (Exception e) {
                throw new IllegalArgumentException("Unable to instantiate " + clazz.getName(), e);
            }

            renderer = filter.getRenderer();
            bounds = renderer.getBounds();

            if (bounds.width > maxWidth) {
                maxWidth = bounds.width;
            }

            this.listFilters.add(filter);
        }

        // Now, given the max width, lay out the boxes for each filter
        xCenter = LIST_BORDER + (maxWidth / 2);
        yPos = LIST_BORDER;

        for (AbstractFilter filt : this.listFilters) {
            renderer = filt.getRenderer();
            bounds = renderer.getBounds();
            renderer.setLocation(xCenter - (bounds.width / 2), yPos);
            yPos += bounds.height + LIST_SPACING;
        }

        yPos += LIST_BORDER;

        this.listWidth = LIST_BORDER + maxWidth + LIST_BORDER;
        setPreferredSize(new Dimension(1000, 500));

        addMouseListener(this);
        addMouseMotionListener(this);
        addKeyListener(this);
    }

    /**
     * Tests whether the panel contains unsaved changes to the active filter.
     *
     * @return  <code>true</code> if there are unsaved changes; <code>false</code> if not
     */
    public boolean isDirty() {

        return this.dirty;
    }

    /**
     * Sets the flag indicating whether the panel contains unsaved changes to the active filter.
     *
     * @param  isDirty  <code>true</code> if there are unsaved changes; <code>false</code> if not
     */
    public void setDirty(final boolean isDirty) {

        this.dirty = isDirty;
    }

    /**
     * Sets the filter tree that this panel will render.
     *
     * @param  filterTree  the filter tree this panel will render
     */
    public void setTree(final FilterTree filterTree) {

        if (filterTree == null) {
            throw new IllegalArgumentException("Filter tree may not be null");
        }

        synchronized (this.synch) {
            this.tree = filterTree;
        }

        repaint();
    }

    /**
     * Deletes any filters in the current filter tree and clears the dirty flag.
     */
    public void clear() {

        while (this.tree.getNumFilters() > 0) {
            this.tree.removeFilter(0);
        }

        this.dirty = false;
        repaint();
    }

    /**
     * Gets the tree of filters this panel is displaying.
     *
     * @return  the root of the filter tree
     */
    public FilterTree getTree() {

        return this.tree;
    }

    /**
     * Paints the panel.
     *
     * @param  grx  the <code>Graphics</code> to which to draw
     */
    @Override public void paintComponent(final Graphics grx) {

        Composite orig;
        Rectangle bounds;
        Point mouse;
        boolean added;

        super.paintComponent(grx);

        if (grx instanceof Graphics2D) {
            ((Graphics2D) grx).setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
                RenderingHints.VALUE_TEXT_ANTIALIAS_GASP);
        }

        paintList(grx);

        // If a drag is in progress, draw the drag result
        if (this.isDragging && (this.dragFilter != null)) {
            mouse = getMousePosition();

            removeProvisionals();

            if ((mouse != null) && (mouse.x > this.listWidth)) {

                // Test for a viable insertion of the dragged node in the tree
                added = checkFilterInsertion(mouse.y);

                // If the node could not be added, render it as a translucent
                // overlay as the user drags it around
                if (!added) {

                    if (grx instanceof Graphics2D) { // NOPMD SRB
                        orig = ((Graphics2D) grx).getComposite();
                        ((Graphics2D) grx).setComposite(ALPHA);
                    } else {
                        orig = null;
                    }

                    bounds = this.dragFilter.getRenderer().getBounds();
                    this.dragFilter.getRenderer().setLocation(mouse.x - (bounds.width / 2),
                        mouse.y - (bounds.height / 2));
                    this.dragFilter.getRenderer().draw(grx);

                    if (grx instanceof Graphics2D) { // NOPMD SRB
                        ((Graphics2D) grx).setComposite(orig);
                    }
                }
            }
        }

        paintTree(grx);
    }

    /**
     * Paints the list portion of the panel.
     *
     * @param  grx  the <code>Graphics</code> to which to draw
     */
    private void paintList(final Graphics grx) {

        int size;
        Composite orig;

        size = this.listFilters.size();

        grx.setColor(Color.lightGray);
        grx.fillRect(0, 0, this.listWidth, getHeight());
        grx.setColor(Color.darkGray);
        grx.drawRect(2, 2, this.listWidth - 4, getHeight() - 4);
        grx.setColor(Color.white);
        grx.drawRect(1, 1, this.listWidth - 4, getHeight() - 4);

        for (int i = 0; i < size; i++) {
            this.listFilters.get(i).getRenderer().draw(grx);
        }

        // If we're dragging a list filter around, render it transsluscent
        if (this.isDragging) {

            if (grx instanceof Graphics2D) {
                orig = ((Graphics2D) grx).getComposite();
                ((Graphics2D) grx).setComposite(ALPHA);
            } else {
                orig = null;
            }

            this.dragFilter.getRenderer().draw(grx);

            if (grx instanceof Graphics2D) {
                ((Graphics2D) grx).setComposite(orig);
            }
        }
    }

    /**
     * Removes any provisional filters from the filter tree.
     */
    private void removeProvisionals() {

        int index;
        AbstractFilter filter;

        index = 0;

        while (index < this.tree.getNumFilters()) {
            filter = this.tree.getFilter(index);

            if (filter.isProvisional()) {
                this.tree.removeFilter(index);
                this.dirty = true;
            } else {
                index++;
            }
        }
    }

    /**
     * Recursively clears the provisional status on any provisional filters in the filter tree.
     */
    private void commitProvisionals() {

        int index;
        AbstractFilter filter;

        index = 0;

        while (index < this.tree.getNumFilters()) {
            filter = this.tree.getFilter(index);

            if (filter.isProvisional()) {
                filter.setProvisional(false);
                this.dirty = true;
            } else {
                index++;
            }
        }
    }

    /**
     * Tests whether the drag location puts the filter being dragged at a point where it could join
     * the tree. In order for a filter to join a tree, there must be a node above the filter in the
     * tree whose output matches each of this node's inputs. If this node has no inputs, the filter
     * can be inserted.
     *
     * <p>If we find a match, the filer being dragged is provisionally added to the tree (and any
     * other provisional filters are removed from the tree).
     *
     * @param   yPos  the Y position of the mouse
     * @return  <code>true</code> if the filter was provisionally added to the tree; <code>
     *          false</code> if not
     */
    private boolean checkFilterInsertion(final int yPos) {

        AbstractFilter test;
        AbstractFilter filter;
        Rectangle bounds;
        boolean isOK;

        test = this.dragFilter;

        if (test == null) {
            isOK = false;
        } else if (test.getNumInputs() == 0) {

            if (this.tree.getNumFilters() == 0) {
                test.setProvisional(true);
                this.tree.addFilter(test);
                this.dirty = true;
                isOK = true;
            } else {
                isOK = false;

                for (int j = this.tree.getNumFilters() - 1; j >= 0; j--) {
                    filter = this.tree.getFilter(j);
                    bounds = filter.getRenderer().getBounds();

                    if ((bounds.getY() + bounds.getHeight()) < yPos) {

                        test.setProvisional(true);
                        this.tree.addFilter(j + 1, test);
                        this.dirty = true;
                        isOK = true;

                        break;
                    }
                }
            }
        } else {
            isOK = testForCompatible(test, yPos);

            if (isOK) {

                for (int j = this.tree.getNumFilters() - 1; j >= 0; j--) {
                    filter = this.tree.getFilter(j);
                    bounds = filter.getRenderer().getBounds();

                    if ((bounds.getY() + bounds.getHeight()) < yPos) {

                        test.setProvisional(true);
                        this.tree.addFilter(j + 1, test);
                        this.dirty = true;

                        break;
                    }
                }
            }
        }

        return isOK;
    }

    /**
     * Walks the filter tree for all nodes whose Y value is less than a given Y value, checking
     * whether all required inputs for a filter are present.
     *
     * @param  test  the filter to be tested
     * @param  yPos  the Y position of the mouse (nodes with Y values higher than this will not be
     *               considered)
     */
    private boolean testForCompatible(final AbstractFilter test, final int yPos) {

        AbstractFilter filter;
        boolean found;
        boolean valid;
        Rectangle bounds;
        FilterInput input;
        FilterOutput output;

        valid = true;

        for (int inIdx = 0; inIdx < test.getNumInputs(); inIdx++) {
            input = test.getInputFormat(inIdx);

            found = false;

outer:
            for (int j = this.tree.getNumFilters() - 1; j >= 0; j--) {
                filter = this.tree.getFilter(j);
                bounds = filter.getRenderer().getBounds();

                if ((bounds.getY() + bounds.getHeight()) < yPos) {

                    for (int outIdx = 0; outIdx < filter.getNumOutputs(); outIdx++) {
                        output = filter.getOutputFormat(outIdx);

                        if (output.type.equals(input.type)) {
                            input.setKey(output.getKey());
                            found = true;

                            break outer;
                        }
                    }
                }
            }

            if (!found) {
                valid = false;

                break;
            }
        }

        return valid;
    }

    /**
     * Draws the filter tree in the window.
     *
     * @param  grx  the <code>Graphics</code> to which to draw
     */
    private void paintTree(final Graphics grx) {

        FontMetrics met;
        int unit;
        int yPos;
        int xPos;
        int xPix;
        int yPix;
        int rightEdge;
        int minX;
        AbstractFilter filter;
        FilterRenderer renderer;
        Rectangle bounds;
        FilterInput inFmt;
        FilterOutput outFmt;
        AbstractFilter testing;
        int intersectX;
        int intersectY;

        met = grx.getFontMetrics();
        unit = met.getHeight() / 6;

        xPos = this.listWidth + (2 * unit);
        yPos = 2 * unit;
        rightEdge = 0;

        for (int i = 0; i < this.tree.getNumFilters(); i++) {
            filter = this.tree.getFilter(i);
            renderer = filter.getRenderer();
            bounds = renderer.getBounds();

            // Determine the minimum X coordinate where the new filter can be drawn
            minX = rightEdge - bounds.width;

            if (filter.getNumInputs() > 0) {
                minX += bounds.width - renderer.getInputX(0) + 4;
            }

            if (xPos < minX) {
                xPos = minX;
            }

            rightEdge = xPos + bounds.width;

            renderer.setLocation(xPos, yPos);
            renderer.draw(grx);
            bounds = renderer.getBounds();

            // Continue the output lines across the diagram
            for (int out = 0; out < filter.getNumOutputs(); out++) {
                xPix = bounds.x + bounds.width;
                yPix = bounds.y + renderer.getOutputY(out);
                grx.setColor(Color.BLACK);
                grx.drawLine(xPix, yPix, getWidth(), yPix);
                grx.setColor(Color.GRAY);
                grx.drawString(filter.getOutputFormat(out).description, xPix + 5,
                    yPix - 2 - met.getDescent());
            }

            // Draw lines linking inputs to prior outputs, including drag handles
            grx.setColor(Color.BLACK);

            for (int in = 0; in < filter.getNumInputs(); in++) {
                inFmt = filter.getInputFormat(in);
                intersectX = bounds.x + renderer.getInputX(in);

outer:
                for (int ii = i - 1; ii >= 0; ii--) {
                    testing = this.tree.getFilter(ii);

                    for (int out = 0; out < testing.getNumOutputs(); out++) {
                        outFmt = testing.getOutputFormat(out);

                        if ((outFmt.type.equals(inFmt.type))
                                && (outFmt.getKey().equals(inFmt.getKey()))) {
                            intersectY = testing.getRenderer().getBounds().y
                                + testing.getRenderer().getOutputY(out);
                            grx.drawLine(intersectX, intersectY, intersectX, yPos);
                            grx.fillOval(intersectX - 3, intersectY - 3, 6, 6);

                            break outer;
                        }
                    }
                }
            }

            yPos += bounds.height + (2 * unit);
        }
    }

    /**
     * Recursively places all filters in the tree in a not selected state.
     *
     * @param  tree  the base of the tree to recurse through
     */
    private void clearSelections() {

        for (int i = 0; i < this.tree.getNumFilters(); i++) {
            this.tree.getFilter(i).setSelected(false);
        }
    }

    /**
     * Handles mouse drag events.
     *
     * @param  evt  the mouse event
     */
    public void mouseDragged(final MouseEvent evt) {

        Point where;
        Rectangle bounds;

        if (isEnabled()) {
            where = evt.getPoint();

            if (isDragging) {
                bounds = this.dragFilter.getRenderer().getBounds();

                if (this.dragFilter.isProvisional()) {

                    if (where.x < this.listWidth) {
                        this.dragFilter.getRenderer().setLocation(where.x - (bounds.width / 2),
                            where.y - (bounds.height / 2));
                    }
                } else {
                    this.dragFilter.getRenderer().setLocation(where.x - (bounds.width / 2),
                        where.y - (bounds.height / 2));
                }
            } else {

                if ((this.dragFilter != null) && (where.distance(this.dragStart) > 30)) {
                    bounds = this.dragFilter.getRenderer().getBounds();
                    this.isDragging = true;

                    if (where.x < this.listWidth) {

                        // When dragging from the list, we duplicate so the list is unchanged
                        this.dragFilter = this.dragFilter.duplicate();
                        this.dragFilter.getRenderer().setLocation(where.x - (bounds.width / 2),
                            where.y - (bounds.height / 2));
                    } else {

                        // When dragging from the tree, delete the filter from the tree
                        for (int i = 0; i < this.tree.getNumFilters(); i++) {

                            if (this.tree.getFilter(i) == this.dragFilter) {
                                this.tree.removeFilter(i);
                                this.dirty = true;

                                break;
                            }
                        }
                    }
                }
            }

            repaint();
        }
    }

    /**
     * Handles mouse move events.
     *
     * @param  evt  the mouse event
     */
    public void mouseMoved(final MouseEvent evt) {

        // No action
    }

    /**
     * Handles mouse click events. We recognize double-clicks and bring up the properties pages for
     * any selected filters.
     *
     * @param  evt  the mouse event
     */
    public void mouseClicked(final MouseEvent evt) {

        AbstractFilter filter;

        if (isEnabled()) {
            requestFocus();

            if (evt.getClickCount() == 2) {

                // See if the double-click occurred on a filter
                for (int i = 0; i < this.tree.getNumFilters(); i++) {

                    filter = this.tree.getFilter(i);

                    if (filter.getRenderer().getBounds().contains(evt.getPoint())) {

                        // TODO: Properties pages
                        LOG.log(Level.INFO, "Properties pages for {0}", filter.getName());

                        break;
                    }
                }
            }
        }
    }

    /**
     * Handles mouse press events.
     *
     * @param  evt  the mouse event
     */
    public void mousePressed(final MouseEvent evt) {

        Point where;
        AbstractFilter filter;

        if (isEnabled()) {

            requestFocus();

            where = evt.getPoint();

            if (where.x > this.listWidth) {

                // Shift key can do multiple selections
                if ((evt.getModifiers() & InputEvent.SHIFT_MASK) == 0) {
                    clearSelections();
                }

                // See if the mouse was pressed inside a filter
                for (int i = 0; i < this.tree.getNumFilters(); i++) {

                    filter = this.tree.getFilter(i);

                    if (filter.getRenderer().getBounds().contains(where)) {

                        filter.setSelected(true);

                        this.dragFilter = filter;
                        this.dragStart = where;
                        this.isDragging = false;

                        break;
                    }
                }

                repaint();
            } else {

                // See if the mouse press was in a filter
                for (AbstractFilter listFilter : this.listFilters) {

                    if (listFilter.getRenderer().getBounds().contains(where)) {
                        this.dragFilter = listFilter;
                        this.dragStart = where;
                        this.isDragging = false;

                        break;
                    }
                }
            }
        }
    }

    /**
     * Handles mouse release events.
     *
     * @param  evt  the mouse event
     */
    public void mouseReleased(final MouseEvent evt) {

        if (isEnabled()) {
            this.dragStart = null;

            if (this.dragFilter != null) {
                commitProvisionals();
                this.dragFilter = null;
            }

            this.isDragging = false;
            repaint();
        }
    }

    /**
     * Handles mouse enter events.
     *
     * @param  evt  the mouse event
     */
    public void mouseEntered(final MouseEvent evt) {

        // No action
    }

    /**
     * Handles mouse exit events.
     *
     * @param  evt  the mouse event
     */
    public void mouseExited(final MouseEvent evt) {

        // No action
    }

    /**
     * Handles key typed events
     *
     * @param  evt  the key event
     */
    public void keyTyped(final KeyEvent evt) {

        // No action
    }

    /**
     * Handles key pressed events
     *
     * @param  evt  the key event
     */
    public void keyPressed(final KeyEvent evt) {

        int index = 0;

        if ((isEnabled() && (evt.getKeyCode() == KeyEvent.VK_DELETE))
                || (evt.getKeyCode() == KeyEvent.VK_BACK_SPACE)) {

            while (index < this.tree.getNumFilters()) {

                if (this.tree.getFilter(index).isSelected()) {
                    this.tree.removeFilter(index);
                    this.dirty = true;
                } else {
                    index++;
                }
            }

            repaint();
        }
    }

    /**
     * Handles key released events
     *
     * @param  evt  the key event
     */
    public void keyReleased(final KeyEvent evt) {

        // No action
    }
}
