/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package org.netbeans.core.output2;

import java.awt.Dimension;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Rectangle;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.JComponent;
import javax.swing.JEditorPane;
import javax.swing.text.BadLocationException;
import javax.swing.text.Document;
import org.netbeans.core.output2.ui.AbstractOutputPane;

/**
 * Component that draws controls for expanding and collapsing of folds.
 *
 * @author jhavlin
 */
public class FoldingSideBar extends JComponent {

    private static final Logger LOG =
            Logger.getLogger(FoldingSideBar.class.getName());
    private final int BAR_WIDTH = 15;

    private final JEditorPane textView;
    private AbstractLines lines;
    private int charsPerLine = 80;
    private boolean wrapped;
    private int activeFold = -1;

    public FoldingSideBar(JEditorPane textView, AbstractOutputPane outputPane) {
        this.textView = textView;
        this.lines = getLines();
        textView.addPropertyChangeListener("document", //NOI18N
                new PropertyChangeListener() {

                    @Override
                    public void propertyChange(PropertyChangeEvent evt) {
                        FoldingSideBar.this.lines = getLines();
                    }
                });
        textView.addComponentListener(new ComponentAdapter() {

            @Override
            public void componentResized(ComponentEvent e) {
                setPreferredSize(new Dimension(BAR_WIDTH,
                        FoldingSideBar.this.textView.getHeight()));
                repaint();
            }
        });
        setMinimumSize(new Dimension(BAR_WIDTH, 0));
        setPreferredSize(new Dimension(BAR_WIDTH, textView.getHeight()));
        setMaximumSize(new Dimension(BAR_WIDTH, Integer.MAX_VALUE));
        wrapped = outputPane.isWrapped();
        addMouseListener(new FoldingMouseListener());
        addMouseMotionListener(new FoldingMouseListener()); //TODO one is enough
    }

    private AbstractLines getLines() {
        Document doc = textView.getDocument();
        if (doc instanceof OutputDocument) {
            Lines l = ((OutputDocument) doc).getLines();
            if (l instanceof AbstractLines) {
                return (AbstractLines) l;
            } else {
                return null;
            }
        } else {
            return null;
        }
    }

    @Override
    public void paintComponent(Graphics g) {
        Rectangle cp = g.getClipBounds();
        g.setColor(getBackground());
        g.fillRect(cp.x, cp.y, cp.width, cp.height);
        if (lines == null) {
            return;
        }
        g.setColor(getForeground());
        FontMetrics fontMetrics = textView.getFontMetrics(textView.getFont());
        int lineHeight = fontMetrics.getHeight();
        int descent = fontMetrics.getDescent();
        int offset = 0;
        try {
            Rectangle modelToView = textView.modelToView(0);
            offset = modelToView == null ? 0 : modelToView.y;
        } catch (BadLocationException ex) {
            LOG.log(Level.INFO, null, ex);
        }
        offset += lineHeight - fontMetrics.getAscent();

        int size = lines.getLineCount();
        int logLine = 0; // logical line (including wrapped lines)
        int nextLogLine;
        int firstVisibleLine = Math.max(0, getLineAtPosition(cp.y) - 1);
        int lastVisibleLine = getLastVisibleLine(cp, size);
        for (int i = firstVisibleLine; i < lastVisibleLine; i++) {
            if (!lines.isVisible(i)) {
                continue;
            }
            nextLogLine = findLogicalLineIndex(findNextVisibleLine(i), size);
            drawLineGraphics(g, i, logLine, nextLogLine, offset, lineHeight,
                    descent);
            logLine = nextLogLine;
        }
    }

    /**
     * Get real index of the last line visible in the current clip bounds.
     *
     * @param cp Clip bounds.
     * @param realLineSize Total count of real lines.
     */
    private int getLastVisibleLine(Rectangle cp, int realLineSize) {
        int lineAtClipBoundsEnd = getLineAtPosition(cp.y + cp.height);
        return lineAtClipBoundsEnd < 0
                ? realLineSize - 1
                : Math.min(realLineSize - 1, lineAtClipBoundsEnd + 1);
    }

    /**
     * Find absolute line number at a y coordinate.
     */
    private int getLineAtPosition(int y) {
        // TODO refactor, the same code as in paint()
        FontMetrics fontMetrics = textView.getFontMetrics(textView.getFont());
        int lineHeight = fontMetrics.getHeight();
        int offset = 0;
        try {
            Rectangle modelToView = textView.modelToView(0);
            offset = modelToView == null ? 0 : modelToView.y;
        } catch (BadLocationException ex) {
            LOG.log(Level.INFO, null, ex);
        }
        offset += lineHeight - fontMetrics.getAscent();
        // end TODO
        int logicalLine = (y - offset) / lineHeight;
        final int physicalLine;
        if (wrapped) {
            int[] info = new int[]{logicalLine, 0, 0};
            lines.toPhysicalLineIndex(info, charsPerLine);
            physicalLine = info[0];
        } else {
            physicalLine = logicalLine < lines.getVisibleLineCount()
                    ? lines.visibleToRealLine(logicalLine)
                    : -1;
        }
        return physicalLine;
    }

    /**
     * @param g Graphics to draw into.
     * @param line Physical line index.
     * @param logLine Logical visible line index.
     * @param nextLogLine Logical index of the next visible line.
     * @param offset Y offset of the first line (pixels).
     * @param lineHeight Height of line (pixels).
     * @param descent Descent of font metrics (pixels).
     */
    private void drawLineGraphics(Graphics g, int line, int logLine,
            int nextLogLine, int offset, int lineHeight, int descent) {

        int currOffset;
        int nextOffset;
        try {
            currOffset = lines.getFoldOffsets().get(line);
            nextOffset = line + 1 < lines.getFoldOffsets().size()
                    ? lines.getFoldOffsets().get(line + 1) : 0;
        } catch (IndexOutOfBoundsException ioobe) { // Some lines were removed.
            LOG.log(Level.FINE, null, ioobe);
            return;
        }
        int startY = logLine * lineHeight + offset;
        int endY = nextLogLine * lineHeight + offset;
        if (nextOffset == 1) {
            drawButton(g, startY, endY, line);
        } else if (currOffset != 0 && currOffset + 1 == nextOffset) {
            if (isActive(line)) {
                g.drawLine(6, startY, 6, endY);
            }
            g.drawLine(7, startY, 7, endY);
        } else if (currOffset > 0 && nextOffset == 0) {
            drawFoldEnd(g, line, startY, lineMid(endY, lineHeight, descent));
        } else if (currOffset > 0 && nextOffset > 0) {
            drawNestedFoldEnd(g, line, startY, endY,
                    lineMid(endY, lineHeight, descent));
        }
    }

    private static int lineMid(int lineEndY, int lineHeight, int descent) {
        return lineEndY - (lineHeight / 2) - descent;
    }

    /**
     * Draw graphics for a line that is at the start of a fold, which includes
     * expand/collapse button.
     *
     * @param g
     * @param lineStartY Y coordinate of the start of the line.
     * @param lineEndY Y coordinate of the end of the line.
     */
    private void drawButton(Graphics g, int lineStartY, int lineEndY, int line) {
        boolean collapsed = !lines.isVisible(line + 1);
        g.drawRect(2, lineStartY, 10, 10);
        g.drawLine(5, lineStartY + 5, 9, lineStartY + 5);
        if (collapsed) {
            g.drawLine(7, lineStartY + 3, 7, lineStartY + 7);
        }
        if (lineEndY > lineStartY + 10
                && (!collapsed || isLastVisibleLineInFold(line))) {
            g.drawLine(7, lineStartY + 10, 7, lineEndY);
            if (isActive(line)) {
                g.drawLine(6, lineStartY + 10, 6, lineEndY);
            }
        }
    }

    private boolean isLastVisibleLineInFold(int line) {
        if (lines.getFoldOffsets().get(line) > 0) {
            int visibleLine = lines.realToVisibleLine(line);
            int nextVisibleRealIndex = lines.visibleToRealLine(visibleLine + 1);
            if (nextVisibleRealIndex >= lines.getFoldOffsets().size()) {
                return true;
            }
            return lines.getFoldOffsets().get(nextVisibleRealIndex) > 0;
        } else {
            return false;
        }
    }

    /**
     * Draw graphics for a line at the end of a nested fold.
     *
     * @param g
     * @param lineStartY Y coordinate of the start of the line.
     * @param lineMid Y coordinate of the middle of the last logical line.
     */
    private void drawNestedFoldEnd(Graphics g, int lineIndex,
            int lineStartY, int lineEndY, int lineMid) {
        g.drawLine(7, lineStartY, 7, lineEndY);
        g.drawLine(7, lineMid, 11, lineMid);
        if (isActive(lineIndex)) {
            g.drawLine(6, lineStartY, 6, lineMid);
            g.drawLine(7, lineMid - 1, 11, lineMid - 1);
            if (isActive(findNextVisibleLine(lineIndex))) {
                g.drawLine(6, lineMid, 6, lineEndY);
            }
        }
    }

    /**
     * Draw graphics for a line at the end of a fold.
     *
     * @param g
     * @param lineStartY Y coordinate of the start of the line.
     * @param lineMid Y coordinate of the middle of the last logical line.
     */
    private void drawFoldEnd(Graphics g, int lineIndex, int lineStartY,
            int lineMid) {
        g.drawLine(7, lineStartY, 7, lineMid);
        g.drawLine(7, lineMid, 11, lineMid);
        if (isActive(lineIndex)) {
            g.drawLine(6, lineStartY, 6, lineMid);
            g.drawLine(7, lineMid - 1, 11, lineMid - 1);
        }
    }

    /**
     * That logical line index for physical line {@code physicalLineIndex}. If
     * the physical line index is bigger or equal to count of physical lines,
     * return total count of logical lines.
     *
     * @param physicalLineIndex Index of physical (not wrapped) visible line.
     * @param size Total count of physical visible lines.
     */
    private int findLogicalLineIndex(int physicalLineIndex, int size) {
        if (wrapped) {
            if (physicalLineIndex < size) {
                return lines.getLogicalLineCountAbove(
                        physicalLineIndex, charsPerLine);
            } else {
                return lines.getLogicalLineCountIfWrappedAt(charsPerLine);
            }
        } else {
            return lines.realToVisibleLine(physicalLineIndex);
        }
    }

    /**
     * Find next visible line below a line.
     *
     * @param physicalLine Physical index of a visible line.
     * @return Physical index of the nearest visible line below
     * {@code physicalLine}.
     */
    private int findNextVisibleLine(int physicalLine) {
        int visibleLineIndex = lines.realToVisibleLine(physicalLine);
        if (visibleLineIndex < 0) {
            return lines.getVisibleLineCount() - 1;
        }
        if (visibleLineIndex + 1 < lines.getVisibleLineCount()) {
            return lines.visibleToRealLine(visibleLineIndex + 1);
        } else {
            return lines.getVisibleLineCount() - 1;
        }
    }

    public void setWrapped(boolean wrapped) {
        this.wrapped = wrapped;
        repaint();
    }

    public void setCharsPerLine(int charsPerLine) {
        this.charsPerLine = charsPerLine;
        repaint();
    }

    /**
     * Check whether the the line belong to the fold under cursor.
     *
     * @param line Physical line index.
     */
    private boolean isActive(int line) {
        int parent = line;
        while (parent != activeFold && parent >= 0) {
            int foldOffset = lines.getFoldOffsets().get(parent);
            if (foldOffset == 0) {
                break;
            } else {
                parent = parent - foldOffset;
            }
        }
        return parent == activeFold;
    }

    private class FoldingMouseListener extends MouseAdapter {

        @Override
        public void mouseExited(MouseEvent e) {
            activeFold = -1;
            repaint();
        }

        @Override
        public void mouseMoved(MouseEvent e) {
            if (lines == null) {
                return;
            }
            int physicalRealLine = getLineForEvent(e);
            updateActiveFold(physicalRealLine);
        }

        @Override
        public void mouseClicked(MouseEvent e) {
            if (lines == null) {
                return;
            }
            int physicalRealLine = getLineForEvent(e);
            updateActiveFold(physicalRealLine);
            if (activeFold == physicalRealLine) {
                if (lines.isVisible(physicalRealLine + 1)) {
                    lines.hideFold(physicalRealLine);
                } else {
                    lines.showFold(physicalRealLine);
                }
            }
        }

        private void updateActiveFold(int physicalLine) {
            int origActiveFold = activeFold;
            if (physicalLine < 0) {
                activeFold = -1;
            } else if (physicalLine + 1 < lines.getFoldOffsets().size()
                    && lines.getFoldOffsets().get(physicalLine + 1) == 1) {
                activeFold = physicalLine;
            } else if (physicalLine < lines.getFoldOffsets().size()) {
                activeFold = physicalLine
                        - lines.getFoldOffsets().get(physicalLine);
            } else {
                activeFold = -1;
            }
            if (activeFold != origActiveFold) {
                repaint();
            }
        }

        private int getLineForEvent(MouseEvent e) {
           return getLineAtPosition(e.getY());
        }
    }
}
