/**
 *  Licensed under GPL. For more information, see
 *    http://jaxodraw.sourceforge.net/license.html
 *  or the LICENSE file in the jaxodraw distribution.
 */
package net.sf.jaxodraw.gui.swing;

import java.awt.Component;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Window;
import java.awt.event.MouseEvent;
import java.awt.event.WindowEvent;
import java.awt.event.WindowFocusListener;

import javax.swing.DefaultButtonModel;
import javax.swing.Icon;
import javax.swing.JTabbedPane;
import javax.swing.SwingUtilities;
import javax.swing.event.MouseInputAdapter;

import net.sf.jaxodraw.util.JaxoUtils;



/** Extension of JTabbedPane with closable tabs.
 *
 * USAGE:
 * <ul>
 * <li>Only use addClosableTab (not addTab, insertTab)
 * <li>Do not use setIconAt, setDisabledIconAt, setTitleAt, but new methds
 * with "closable" in the name. For a disabled icon, use a custom icon that
 * knows how to paint itself disabled.
 * <li>getIconAt, getDisabledIconAt, getTitleAt, are not meaningful.
 * </ul>
 * For general-purpose use, this implementation lacks support for disabled state
 * both of tabs and of the tab pane), support for RTL orientation and
 * improved accessibility code (showing the
 * titles as accessible names). Neither is of importance for JaxoDraw at the moment.
 * @since 2.0
 */
// GTK: does not paint decent focus rectangle - its own fault
public class JaxoClosableTabbedPane extends JTabbedPane {
    private static final long serialVersionUID = 7526471155622776147L;
    // Some insets are needed - otherwise the focus border may be too close
    // to the close button, which looks ugly
    private static final Insets TAB_INSETS = new Insets(3, 0, 3, 0);
    private final IndexButtonModel button;
    private final IndexButtonModel tabRollover;
    private final CloseButtonListener closeButtonListener;

    // all of these icons must have the same size
    private final Icon defaultIcon; // default
    private final Icon selectedIcon; // selected tab (unless rollover or pressed)
    private final Icon rolloverIcon; // rollover (even for unselected tab), unless pressed
    private final Icon pressedIcon; // pressed (but not armed)
    private final Icon pressedArmedIcon; // pressed (and armed)

    /**
     * Constructor.
     */
    public JaxoClosableTabbedPane() {
        super();
        defaultIcon = JaxoUtils.newImageIcon("closabletab_notselected.png");
        selectedIcon = JaxoUtils.newImageIcon("closabletab_selected.png");
        rolloverIcon = JaxoUtils.newImageIcon("closabletab_close.png");
        pressedIcon = JaxoUtils.newImageIcon("closabletab_selected.png");
        pressedArmedIcon = JaxoUtils.newImageIcon("closabletab_close.png");

        tabRollover = new IndexButtonModel();
        button = new IndexButtonModel();

        closeButtonListener = new CloseButtonListener();

        // Do NOT enable scroll mode
        addMouseListener(closeButtonListener);
        addMouseMotionListener(closeButtonListener);
    }

    /** {@inheritDoc} */
    @Override
    public void addNotify() {
        super.addNotify();

        final Window w = SwingUtilities.getWindowAncestor(this);

        if (w != null) {
            w.addWindowFocusListener(closeButtonListener);
        }
    }

    /** {@inheritDoc} */
    @Override
    public void removeNotify() {
        super.removeNotify();

        final Window w = SwingUtilities.getWindowAncestor(this);

        if (w != null) {
            w.removeWindowFocusListener(closeButtonListener);
        }

        closeButtonListener.reset();
    }

    /** {@inheritDoc} */
    @Override
    public void updateUI() {
        super.updateUI();

        // just in case
        updateAllCombinedIcons();
    }

    /** {@inheritDoc} */
    @Override
    public void setFont(final Font font) {
        final boolean changed = font != getFont();

        super.setFont(font);

        if (changed) {
            updateAllCombinedIcons();
        }
    }

    /**
     * {@inheritDoc} DO NOT USE. Use any of the addClosableTab methods.
     */
    @Override
    public void insertTab(final String title, final Icon icon, final Component component, final String tip,
        final int index) {
        super.insertTab(title, icon, component, tip, index);

        tabAdded(index);
    }

    /** {@inheritDoc} */
    @Override
    public void removeTabAt(final int index) {
        super.removeTabAt(index);

        tabRemoved(index);
    }

    // Icon size may change, e.g., if the font is changed
    private void updateAllCombinedIcons() {
        for (int i = getTabCount() - 1; i >= 0; --i) {
            final CombinedIcon old = getCombinedIconAt(i);

            setIconAt(i, (Icon) old.copy());
        }
    }

    private CombinedIcon getCombinedIconAt(final int index) {
        return (CombinedIcon) getIconAt(index);
    }

    /**
     * Is the tab at 'index' closable by the user?
     * @param index The index of the tab to check.
     * @return True if the tab is closeable.
     */
    public boolean isClosableAt(final int index) {
        return getCombinedIconAt(index).isClosable();
    }

    /**
     * Sets the tab at index as closeable.
     * @param index The index of the tab.
     * @param value True if the tab should be closeable.
     */
    public void setClosableAt(final int index, final boolean value) {
        if (value != isClosableAt(index)) {
            setIconAt(index, getCombinedIconAt(index).cloneWithClosable(value));
        }
    }

    /**
     * Font style (PLAIN, BOLD, ITALIC, BOLD|ITALIC) of tab title
     * at 'index'. The default is plain.
     * @param index The index of the tab.
     * @return The font style.
     */
    public int getFontStyleAt(final int index) {
        return getCombinedIconAt(index).getFontStyle();
    }

    /**
     * Sets the font style of the tab at index.
     * @param index The index of the tab.
     * @param value The font style to set.
     */
    public void setFontStyleAt(final int index, final int value) {
        if (value != getFontStyleAt(index)) {
            setIconAt(index, getCombinedIconAt(index).cloneWithFontStyle(value));
        }
    }

    /**
     * Inserts a tab.
     * @param title The tab title.
     * @param c The component to be displayed when this tab is clicked.
     */
    public void addClosableTab(final String title, final Component c) {
        addClosableTab(getTabCount(), title, null, c);
    }

    /**
     * Inserts a tab.
     * @param title The tab title.
     * @param n An icon for the tab.
     * @param c The component to be displayed when this tab is clicked.
     */
    public void addClosableTab(final String title, final Icon n, final Component c) {
        addClosableTab(getTabCount(), title, n, c);
    }

    /**
     * Inserts a tab.
     * @param index The tab index.
     * @param title The tab title.
     * @param c The component to be displayed when this tab is clicked.
     */
    public void addClosableTab(final int index, final String title, final Component c) {
        addClosableTab(index, title, null, c);
    }

    /** Add a tab at 'index' with given title and icon,
     * showing the given component. By default, the tab is closable.
     * This method (or one of the delegates) must be used to add new tabs.
     * @param index The tab index.
     * @param title The tab title.
     * @param n An icon for the tab.
     * @param c The component to be displayed when this tab is clicked.
     */
    public void addClosableTab(final int index, final String title, final Icon n, final Component c) {
        insertTab(null, new CombinedIcon(index, title, n), c, null, index);
    }

    /**
     * Use instead of setTitleAt.
     * @param index The index of the tab.
     * @param value The title to set.
     */
    public void setClosableTitleAt(final int index, final String value) {
        final String old = getClosableTitleAt(index);
        if ((value == null) ? (old != null) : (!value.equals(old))) {
            setIconAt(index, getCombinedIconAt(index).cloneWithTitle(value));
        }
    }

    /**
     * Use instead of getTitleAt.
     * @param index The index of the tab.
     * @return The title of the tab at index.
     */
    public String getClosableTitleAt(final int index) {
        return getCombinedIconAt(index).getTitle();
    }

    /**
     * Use instead of setIconAt.
     * @param index The index of the tab.
     * @param value The icon to set.
     */
    public void setClosableIconAt(final int index, final Icon value) {
        if (value != getClosableIconAt(index)) {
            setIconAt(index, getCombinedIconAt(index).cloneWithIcon(value));
        }
    }

    /**
     * Use instead of getIconAt.
     * @param index The index of the tab.
     * @return The icon of the tab.
     */
    public Icon getClosableIconAt(final int index) {
        return getCombinedIconAt(index).getIcon();
    }

    /**
     * Standard listener pattern.
     * @param l The listener to add.
     */
    public void addClosingListener(final TabClosingListener l) {
        listenerList.add(TabClosingListener.class, l);
    }

    /**
     * Standard listener pattern.
     * @param l The listener to remove.
     */
    public void removeClosingListener(final TabClosingListener l) {
        listenerList.remove(TabClosingListener.class, l);
    }

    /**
     * Standard listener pattern.
     * @param index The index of the tab.
     */
    protected void fireClosing(final int index) {
        final Object[] pairs = listenerList.getListenerList();

        TabClosingEvent e = null;

        for (int i = pairs.length - 2; i >= 0; i -= 2) {
            if (pairs[i] == TabClosingListener.class) {
                if (e == null) {
                    e = new TabClosingEvent(this, index);
                }
                ((TabClosingListener) pairs[i + 1]).closing(e);
            }
        }
    }

    /** Programmatically cause the same effect (event) as
     * if the user made a closing action at index. The index
     * is required to be closable.
     * @param index The tab to close.
     */
    public void closingTab(final int index) {
        if (!isClosableAt(index)) {
            throw new IllegalArgumentException("Not closable " + index);
        }

        fireClosing(index);
    }

    //////////////////
    // Adjust indices.
    private void tabRemoved(final int index) {
        closeButtonListener.tabRemoved(index);

        for (int i = index; i < getTabCount(); i++) {
            --getCombinedIconAt(i).index;
        }
    }

    private void tabAdded(final int index) {
        closeButtonListener.tabAdded(index);

        for (int i = index + 1; i < getTabCount(); i++) {
            getCombinedIconAt(i).index++;
        }
    }

    // update the rollover tab: 'index' has changed to 'value', and there
    // can be at most one rollover tab
//  COMMENT OUT TO SEE ROLLOVER:
//    private void updateRolloverAt(int index, boolean value) {
//        setBackgroundAt(index, value ? java.awt.Color.blue : null);
//    }

    private static class IndexButtonModel extends DefaultButtonModel {
        private static final long serialVersionUID = 7526471155622776147L;
        private int index;

        IndexButtonModel() {
            super();
            index = -1;
        }

        public void setIndex(final int value) {
            index = value;
        }

        public void shiftIndex(final int value) {
            index += value;
        }

        public void insertIndex(final int value) {
            if (index >= value) {
                index++;
            }
        }

        public void removeIndex(final int value) {
            if (index == value) {
                index = -1;
            } else if (index > value) {
                --index;
            }
        }

        public int getIndex() {
            return index;
        }

        public void reset() {
            setIndex(-1);
        }
    }

    // It is assumed that 'button.index' is either -1 or the
    // same as 'tabRollover.index'.
    private class CloseButtonListener extends MouseInputAdapter
        implements WindowFocusListener {
        private int pressedCount;

        public void windowGainedFocus(final WindowEvent e) {
            // nop
        }

        public void windowLostFocus(final WindowEvent e) {
            // modal dialog cleanup, otherwise things are
            // active even if the window does not /have/ focus
            reset();
        }

        public void reset() {
            setRollover(-1, false);
            pressedCount = 0;
        }

        // Adjust index on tab changes
        // ignore that tab bounds may shift
        public void tabRemoved(final int index) {
            tabRollover.removeIndex(index);
            button.removeIndex(index);
        }

        public void tabAdded(final int index) {
            tabRollover.insertIndex(index);
            button.insertIndex(index);
        }

        private void repaintAt(final IndexButtonModel m) {
            if (m.getIndex() != -1) {
                repaint(getBoundsAt(m.getIndex()));
            }
        }

        private boolean isContainedInButton(final int index, final Point p) {
            return (index != -1)
            && getCombinedIconAt(index).isContainedInButton(p);
        }

        private void setRollover(final int index, final boolean inButton) {
            if (index == -1) {
                repaintAt(tabRollover);
                //if (tabRollover.getIndex() != -1) {
                //    updateRolloverAt(tabRollover.getIndex(), false);
                //}
                tabRollover.reset();
                button.reset();
            } else {
                final boolean noChange =
                    tabRollover.isRollover()
                    && (tabRollover.getIndex() == index)
                    && (inButton == button.isRollover());

                if (!noChange) {
                    tabRollover.setRollover(true);
                    //if (tabRollover.getIndex() != -1) {
                    //    updateRolloverAt(tabRollover.getIndex(), false);
                    //}
                    tabRollover.setIndex(index);
                    //updateRolloverAt(tabRollover.getIndex(), true);

                    button.setIndex(inButton ? index : (-1));
                    button.setRollover(inButton);

                    repaintAt(tabRollover);
                }
            }
        }

        @Override
        public void mouseMoved(final MouseEvent e) {
            if (pressedCount == 0) {
                final int index = indexAtLocation(e.getX(), e.getY());

                final boolean inButton = isContainedInButton(index, e.getPoint());

                setRollover(index, inButton);
            } else {
                setRollover(-1, false);
            }
        }

        @Override
        public void mousePressed(final MouseEvent e) {
            if (pressedCount == 0) {
                mouseMoved(e);
            } else {
                mouseDragged(e);
            }

            pressedCount++;

            if (!SwingUtilities.isLeftMouseButton(e)) {
                return;
            }

            if (button.getIndex() != -1) {
                button.setPressed(true);
                button.setArmed(true);
                repaintAt(button);
            }
        }

        @Override
        public void mouseDragged(final MouseEvent e) {
            if (!button.isPressed()) {
                mouseMoved(e);
                return;
            }

            final int index = indexAtLocation(e.getX(), e.getY());

            final boolean armed =
                (index == button.getIndex())
                && isContainedInButton(index, e.getPoint());

            if (armed != button.isArmed()) {
                button.setArmed(armed);

                repaintAt(button);
            }
        }

        @Override
        public void mouseReleased(final MouseEvent e) {
            mouseDragged(e);

            --pressedCount;

            if (SwingUtilities.isLeftMouseButton(e) && button.isPressed()) {
                if (button.isArmed()) {
                    fireClosing(button.getIndex());
                }

                repaintAt(button);

                button.reset();
            }

            if (isNoButtonDown(e)) {
                mouseMoved(e);
            }
        }

        private boolean isNoButtonDown(final MouseEvent e) {
            return (
                e.getModifiersEx()
                & (
                    MouseEvent.BUTTON1_DOWN_MASK
                    | MouseEvent.BUTTON2_DOWN_MASK
                    | MouseEvent.BUTTON3_DOWN_MASK
                )
            ) == 0;
        }

        @Override
        public void mouseEntered(final MouseEvent e) {
            if (pressedCount > 0) { // drag
                mouseDragged(e);
            } else if (isNoButtonDown(e)) { // move
                mouseMoved(e);
            }

            // else foreign entered
        }

        @Override
        public void mouseExited(final MouseEvent e) {
            if (pressedCount > 0) { // drag
                mouseDragged(e);
            } else if (isNoButtonDown(e)) { // move
                mouseMoved(e);
            }

            // else foreign exited
        }
    }

    // Combined Icon that paints the real Icon, the title, and the button
    // This is immutable because it is not really possible for an icon to
    // change size. Use cloneWithXYZ methods to obtain a new CombinedIcon
    // with a changed property.
    private class CombinedIcon implements Icon, Cloneable {
        // modified by tabAdded/tabRemoved in the main class.
        private int index;

        // Visible properties
        private String title;
        private Icon icon;
        private boolean closable;
        private int fontStyle;

        // Icon/Text gap is modelled as a width of a space
        // (Icon and or Text)/Button gap is modelled as two spaces
        // gap is lazily initialized when first needed
        private int gap;
        private Rectangle lastButtonBounds;

        CombinedIcon(final int ind, final String text, final Icon ico) {
            this.closable = true;
            this.index = ind;
            this.icon = ico;
            this.fontStyle = Font.PLAIN;
            this.title = text;
            this.gap = -1;

        }

        public final int getFontStyle() {
            return fontStyle;
        }

        public final String getTitle() {
            return title;
        }

        public final Icon getIcon() {
            return icon;
        }

        public final boolean isClosable() {
            return closable;
        }

        @Override
        public Object clone() throws CloneNotSupportedException {
            final CombinedIcon clone = (CombinedIcon) super.clone();

            clone.gap = -1;
            clone.lastButtonBounds = null;

            return clone;
        }

        public Object copy() {
            try {
                return clone();
            } catch (CloneNotSupportedException ex) {
                throw new Error(ex);
            }
        }

        public Icon cloneWithFontStyle(final int value) {
            final CombinedIcon clone = (CombinedIcon) copy();

            clone.fontStyle = value;

            return clone;
        }

        public Icon cloneWithIcon(final Icon value) {
            final CombinedIcon clone = (CombinedIcon) copy();

            clone.icon = value;

            return clone;
        }

        public Icon cloneWithTitle(final String value) {
            final CombinedIcon clone = (CombinedIcon) copy();

            clone.title = value;

            return clone;
        }

        public Icon cloneWithClosable(final boolean value) {
            final CombinedIcon clone = (CombinedIcon) copy();

            clone.closable = value;

            return clone;
        }

        public boolean isContainedInButton(final Point p) {
            if (!closable) {
                return false;
            }

            if (lastButtonBounds == null) {
                return false;
            }

            return lastButtonBounds.contains(p);
        }

        protected Font getTabFont() {
            Font f = getFont();
            if (f.getStyle() != fontStyle) {
                f = f.deriveFont(fontStyle);
            }
            return f;
        }

        public void paintIcon(final Component c, final Graphics gg, final int xx, final int yy) {
            final Graphics g = gg.create();
            int x = xx;
            int y = yy;

            g.setColor(getForegroundAt(index));
            g.setFont(getTabFont());

            x += TAB_INSETS.left;
            y += TAB_INSETS.top;

            final int height = getIconHeight() - TAB_INSETS.top - TAB_INSETS.bottom;

            final FontMetrics m = g.getFontMetrics();

            if (gap == -1) {
                gap = m.charWidth(' ');
            }

            // Horizontal
            final int titleWidth = (title == null) ? 0 : m.stringWidth(title);
            final int iconTitleGap = ((icon == null) || (title == null)) ? 0 : gap;
            final int titleButtonGap =
                ((iconTitleGap != 0) && closable) ? (2 * gap) : 0;

            // Vertical
            final int titleHeight = m.getHeight();
            final int iconHeight = (icon == null) ? 0 : icon.getIconHeight();

            final int shift = (titleHeight - iconHeight) / 2;

            final int textDY = (shift < 0) ? shift : 0;
            final int iconDY = (shift > 0) ? shift : 0;

            final int textIconDY =
                Math.max(0, height - Math.max(titleHeight, iconHeight)) / 2;

            if (icon != null) {
                icon.paintIcon(c, g, x, y + iconDY + textIconDY);

                x += icon.getIconWidth();
            }

            if (title != null) {
                x += iconTitleGap;

                g.setColor(getForegroundAt(index));
                g.setFont(getTabFont());

                g.drawString(title, x, y + m.getAscent() + textDY + textIconDY);

                x += titleWidth;
            }

            if (closable) {
                x += titleButtonGap;

                final Dimension d =
                    new Dimension(defaultIcon.getIconWidth(),
                        defaultIcon.getIconHeight());

                Icon paintedButtonIcon;

                if (index == button.getIndex()) {
                    if (button.isPressed()) {
                        paintedButtonIcon =
                            button.isArmed() ? pressedArmedIcon : pressedIcon;
                    } else {
                        paintedButtonIcon = rolloverIcon;
                    }
                } else if (index == getSelectedIndex()) {
                    paintedButtonIcon = selectedIcon;
                } else {
                    paintedButtonIcon = defaultIcon;
                }

                if (lastButtonBounds == null) {
                    lastButtonBounds = new Rectangle();
                }

                lastButtonBounds.setBounds(x, y + ((height - d.height) / 2),
                    d.width, d.height);

                paintedButtonIcon.paintIcon(c, g, x,
                    y + ((height - d.height) / 2));
            }

            g.dispose();
        }

        public int getIconWidth() {
            final FontMetrics m = getFontMetrics(getTabFont());

            if (gap == -1) {
                gap = m.charWidth(' ');
            }

            final int titleWidth = (title == null) ? 0 : m.stringWidth(title);
            final int iconWidth = (icon == null) ? 0 :  icon.getIconWidth();
            final int iconTitleGap = ((icon == null) || (title == null)) ? 0 : gap;
            final int buttonWidth = closable ? defaultIcon.getIconWidth() : 0;
            final int titleButtonGap =
                ((iconTitleGap != 0) && closable) ? (2 * gap) : 0;

            return TAB_INSETS.left + TAB_INSETS.right + iconWidth
            + iconTitleGap + titleWidth + titleButtonGap + buttonWidth;
        }

        public int getIconHeight() {
            final FontMetrics m = getFontMetrics(getTabFont());

            final int titleHeight = m.getHeight();
            final int iconHeight = (icon == null) ? 0 : icon.getIconHeight();

            // Use button height for uniform layout even if not closable
            return TAB_INSETS.top + TAB_INSETS.bottom
            + Math.max(defaultIcon.getIconHeight(),
                Math.max(titleHeight, iconHeight));
        }
    }
}
