/*
 * 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.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.*;
import javax.swing.event.*;
import javax.swing.text.*;
import java.util.ArrayList;
import java.util.List;
import org.netbeans.core.output2.ui.AbstractOutputPane;
import org.openide.util.Exceptions;


/** An implementation of Document directly over a memory mapped file such that
 * no (or nearly no) memory copies are required to fetch data to display.
 *
 * @author  Tim Boudreau, Jesse Glick
 */
public class OutputDocument implements Document, Element, ChangeListener {
    private static final Logger LOG =
            Logger.getLogger(OutputDocument.class.getName());
    private List<DocumentListener> dlisteners = new ArrayList<DocumentListener>();
    private volatile Timer timer = null;

    private OutWriter writer;
    
    private StringBuffer inBuffer;
    private AbstractOutputPane pane;
    private int lastInputOff = -1; // offset of start of input string, #142721
   
    /** Creates a new instance of OutputDocument */
    OutputDocument(OutWriter writer) {
        if (Controller.LOG) {
            Controller.log ("Creating a Document for " + writer);
        }
        this.writer = writer;
        getLines().addChangeListener(this);
        inBuffer = new StringBuffer();
    }

    OutWriter getWriter() {
        return writer;
    }

    //#119985
    public int getOutputLength() {
        return getLines().getCharCount();
    }
    
    //#114290
    public void setPane(AbstractOutputPane pane) {
        this.pane = pane;
    }

    /**
     * Destroy this OutputDocument and its backing storage.  The document should not be visible
     * in the UI when this method is called.
     */
    public void dispose() {
        if (Controller.LOG) Controller.log ("Disposing document and backing storage for " + getLines().readLock());
        disposeQuietly();
        writer.dispose();
        writer = null;
    }

    /**
     * Destory this OutputDocument, but not its backing storage.  The document should not be
     * visible in the UI when this method is called.
     */
    public void disposeQuietly() {
        if (timer != null) {
            timer.stop();
            timer = null;
        }
        dlisteners.clear();
        lastEvent = null;
        getLines().removeChangeListener(this);
    }

    public synchronized void addDocumentListener(DocumentListener documentListener) {
        dlisteners.add (documentListener);
        lastEvent = null;
    }
    
    public void addUndoableEditListener(UndoableEditListener l) {
        //do nothing
    }
    
    public Position createPosition(int offset) throws BadLocationException {
        if (offset < 0 || offset > getLines().getCharCount() + inBuffer.length()) {
            throw new BadLocationException ("Bad position", offset); //NOI18N
        }
        //TODO
        return new ODPosition (offset);
    }
    
    public Element getDefaultRootElement() {
        return this;
    }
    
    public Position getEndPosition() {
        return new ODEndPosition();
    }
    
    public int getLength() {
        return getLines().getCharCount() + inBuffer.length();
    }
    
    public Object getProperty(Object obj) {
        return null;
    }

    public Element[] getRootElements() {
        return new Element[] {this};
    }
    
    public Position getStartPosition() {
        return new ODStartPosition();
    }
    
    public String getText(int offset, int length) throws BadLocationException {
        if (length == 0) {
            return ""; //NOI18N
        }
        String result;
        synchronized (getLines().readLock()) {
            if (offset < 0 || offset + length > getLines().getCharCount() + inBuffer.length() || length < 0) {
                throw new BadLocationException("Bad: " + offset + "," + //NOI18N
                        length + " (" + getLines().getCharCount() + ", " + inBuffer.length() + ")", offset);
            }
            int linesOffset = Math.min(getLines().getCharCount(), offset);
            int linesEnd = Math.min(getLines().getCharCount(), offset + length);
            result = getLines().getText(linesOffset, linesEnd);
            if (offset + length > getLines().getCharCount()) {
                int inEnd = offset + length - getLines().getCharCount();
                result = result + inBuffer.substring(0, inEnd);
            }
        }
        return result;
    }
    
    private char[] reusableSubrange = new char [256];
    public void getText(int offset, int length, Segment txt) throws BadLocationException {
        if (length < 0) {
            //document is empty
            txt.array = new char[0];
            txt.offset=0;
            txt.count = 0;
            return;
        }
        
        if (offset < 0) {
            throw new BadLocationException ("Negative offset", offset); //NOI18N
        }
        if (getLines().getLineCount() == -1) {
            txt.array = new char[] {'\n'};
            txt.offset = 0;
            txt.count = 1;
            return;
        }
        if (length > reusableSubrange.length) {
            reusableSubrange = new char[length];
        }
        try {
            synchronized (getLines().readLock()) {
                int charCount = getLines().getCharCount();
                // #180404
                if (charCount < 0) {
                    txt.array = new char[0];
                    txt.offset=0;
                    txt.count = 0;
                    return;
                }
                int linesOffset = Math.min(charCount, offset);
                int linesEnd = Math.min(charCount, offset + length);
                char[] chars = getLines().getText(linesOffset, linesEnd, reusableSubrange);
                if (offset + length > charCount) {
                    int inEnd = offset - charCount + length;
                    int inStart = Math.max(0, offset - charCount);
                    // calling Math.min to prevent nasty AOOBE wich seem to come out of nowhere..
                    inBuffer.getChars(Math.min(inStart, inBuffer.length()), Math.min(inEnd, inBuffer.length()), 
                            chars, linesEnd - linesOffset);
                }
                txt.array = chars;
                txt.offset = 0;
                txt.count = Math.min(length, chars.length);
            }
        } catch (OutOfMemoryError error) {
            //#50189 - try to salvage what we can
            OutWriter.lowDiskSpace = true;
            //mkleint: is not necessary low disk space, can also mean too many mapped buffers were requirested too fast..
            //Sets the error flag and releases the storage
            writer.dispose();
            Logger.getAnonymousLogger().log(Level.WARNING,
                "OOME while reading output.  Cleaning up.", //NOI18N
            error);
            System.gc();
        }
    }
    public void insertString(int offset, String str, AttributeSet attributeSet) throws BadLocationException {
        final int off;
        final int docLen = getLength();
        final int inputOff = docLen - inBuffer.length(); // start of input
        if (inputOff != lastInputOff) { // output written since last input
            lastInputOff = inputOff;
            off = docLen;
        } else if (offset < inputOff) { // cursor before start of input
            off = inputOff;
        } else {
            off = Math.min(offset, inBuffer.length() + inputOff);
        }
        inBuffer.insert(off - inputOff, str);
        final int len = str.length();
        DocumentEvent ev = new DocumentEvent() {
            public int getOffset() {
                return off;
            }

            public int getLength() {
                return len;
            }

            public Document getDocument() {
                return OutputDocument.this;
            }

            public EventType getType() {
                return EventType.INSERT;
            }

            public ElementChange getChange(Element arg0) {
                return null;
            }
        };

        if (getLines() instanceof AbstractLines) {
            AbstractLines lines = (AbstractLines) getLines();
            int start = lines.getLineStart(lines.getLineCount() - 1);
            int length = getLength() - start;
            lines.lineUpdated(2*start, 2*length, length, false);
        }
        fireDocumentEvent(ev);
    }
    
    public String sendLine() {
        final int off = getLength() - inBuffer.length();
        final int len = inBuffer.length();
        String toReturn = inBuffer.toString();
        inBuffer = new StringBuffer();
        DocumentEvent ev = new DocumentEvent() {
            public int getOffset() {
                return off;
            }

            public int getLength() {
                return len;
            }

            public Document getDocument() {
                return OutputDocument.this;
            }

            public EventType getType() {
                return EventType.REMOVE;
            }

            public ElementChange getChange(Element arg0) {
                return null;
            }
        };
        if (getLines() instanceof AbstractLines) {
            AbstractLines lines = (AbstractLines) getLines();
            int start = lines.getLineStart(lines.getLineCount() - 1);
            lines.lineUpdated(2*start, 0, 0, false);
        }        
        fireDocumentEvent(ev);
        return toReturn;
    }
    
    public void putProperty(Object obj, Object obj1) {
        //do nothing
    }
    
    public void remove(int offset, int length) throws BadLocationException {
        int startOff = getLength() - inBuffer.length();
        final int off = Math.max(startOff, offset);
        final int len = Math.min(length, inBuffer.length());
        if (startOff != lastInputOff) { // output written since last input
            lastInputOff = startOff;
            inBuffer.delete(inBuffer.length() - len, inBuffer.length());
        } else if (off - startOff + len <= getLength()) {
            inBuffer.delete(off - startOff, off - startOff + len);
            DocumentEvent ev = new DocumentEvent() {
            public int getOffset() {
                return off;
            }

            public int getLength() {
                return len;
            }

            public Document getDocument() {
                return OutputDocument.this;
            }

            public EventType getType() {
                return EventType.REMOVE;
            }

            public ElementChange getChange(Element arg0) {
                return null;
            }
            };
            if (getLines() instanceof AbstractLines) {
                AbstractLines lines = (AbstractLines) getLines();
                int start = lines.getLineStart(lines.getLineCount() - 1);
                int l = getLength() - start;
                lines.lineUpdated(2*start, 2*l, l, false);
            }            
            fireDocumentEvent(ev);
        }
    }
    
    public synchronized void removeDocumentListener(DocumentListener documentListener) {
        dlisteners.remove(documentListener);
        lastEvent = null;
        if (dlisteners.isEmpty() && timer != null) {
            timer.stop();
            timer = null;
        }
    }

    public Lines getLines() {
        //Unit test will check for null to determine if dispose succeeded
        return writer != null ? writer.getLines() : null;
    }

    public int getLineStart (int line) {
        return getLines().getLineCount() > 0 ? getLines().getLineStart(line) : 0;
    }

    public int getLineEnd (int lineIndex) {
        if (getLines().getLineCount() == 0) {
            return 0;
        }
        int endOffset;
        if (lineIndex >= getLines().getLineCount()-1) {
            endOffset = getLines().getCharCount() + inBuffer.length();
        } else {
            endOffset = getLines().getLineStart(lineIndex+1) - 1;
        }
        return endOffset;
    }

    public void removeUndoableEditListener(UndoableEditListener undoableEditListener) {
        //do nothing
    }
    
    public void render(Runnable runnable) {
        getElementCount(); //Force a refresh of lastPostedLine
        runnable.run();
    }
    
    public AttributeSet getAttributes() {
        return SimpleAttributeSet.EMPTY;
    }
    
    public Document getDocument() {
        return this;
    }
    
    public Element getElement(int index) {
        int realIndex = getLines().visibleToRealLine(index);
        return new ODElement(realIndex);
    }
    
    public int getElementCount() {
        return Math.max(1, getLines().getVisibleLineCount());
    }
    
    public int getElementIndex(int offset) {
        int realLine = getLines().getLineAt(offset);
        if (realLine < 0) {
            return realLine;
        } else {
            return getLines().realToVisibleLine(realLine);
        }
    }
    
    public int getEndOffset() {
        return getLength() + 1;
    }
    
    public String getName() {
        return "foo"; //XXX
    }
    
    public Element getParentElement() {
        return null;
    }
    
    public int getStartOffset() {
        return 0;
    }
    
    public boolean isLeaf() {
        return false;
    }

    private volatile DO lastEvent = null;
    private int lastFiredLineCount = 0;
    private int lastFiredLength = 0;
    private int lastVisibleLineCount = 0;
    public void stateChanged(ChangeEvent changeEvent) {
        assert SwingUtilities.isEventDispatchThread();

        if (Controller.VERBOSE) Controller.log(changeEvent != null ? "Document got change event from writer" : "Document timer polling");
        if (dlisteners.isEmpty()) {
            if (Controller.VERBOSE) Controller.log("listeners empty, not firing");
            return;
        }
        Lines lines = getLines();
        if (lines.checkDirty(true)) {
            if (lastEvent != null && !lastEvent.isConsumed()) {
                if (Controller.VERBOSE) Controller.log("Last event not consumed, not firing");
                return;
            }
            boolean lastLineChanged;
            int lineCount;
            int visibleLineCount;
            int size;
            synchronized (lines.readLock()) {
                lineCount = lines.getLineCount();
                visibleLineCount = lines.getVisibleLineCount();
                size = lines.getCharCount() + inBuffer.length();
                if (size == lastFiredLength && visibleLineCount == lastVisibleLineCount) {
                    // nothing changed
                    if (Controller.VERBOSE) {
                        Controller.log("Size is same " + size + " - not firing");
                    }
                    return;
                }
                lastLineChanged = lastFiredLineCount == lineCount;
                if (lastFiredLineCount > 0 && lineCount > lastFiredLineCount) {
                    int lastFiredLineEnd = lines.getLineStart(lastFiredLineCount);
                    if (lastFiredLineEnd > lastFiredLength) {
                        lastLineChanged = true;
                    }
                }
            }
            if (lastFiredLineCount == lineCount
                    && lastVisibleLineCount != visibleLineCount) {
                lastEvent = new DO(0);
            } else {
                lastEvent = new DO(
                        lastLineChanged
                        ? lastFiredLineCount - 1
                        : lastFiredLineCount);
            }
            lastFiredLineCount = lineCount;
            lastVisibleLineCount = visibleLineCount;
            lastFiredLength = size;

            if (Controller.VERBOSE) Controller.log("Firing document event on EQ with start index " + lastEvent.first);
            fireDocumentEvent(lastEvent);
            if (pane != null) {
                pane.getFoldingSideBar().repaint();
            }
        } else {
            if (Controller.VERBOSE) Controller.log("Writer says it is not dirty, firing no change");
        }
    }

    private void fireDocumentEvent (DocumentEvent de) {
        for (DocumentListener dl: new ArrayList<DocumentListener>(dlisteners)) {
            //#114290
            if (!(de instanceof DO)) {
                if (pane != null) {
                    pane.doUpdateCaret();
                }
            }
            if (de.getType() == DocumentEvent.EventType.REMOVE) {
                dl.removeUpdate(de);
            } else if (de.getType() == DocumentEvent.EventType.CHANGE) {
                dl.changedUpdate(de);
            } else {
                dl.insertUpdate(de);
            }
            //#114290
            if (!(de instanceof DO)) {
                if (pane != null) {
                    pane.dontUpdateCaret();
                }
            }
        }
    }

    static final class ODPosition implements Position {
        private int offset;
        
        ODPosition (int offset) {
            this.offset = offset;
        }
        
        public int getOffset() {
            return offset;
        }
        
        @Override
        public int hashCode() {
            return offset * 11;
        }
        
        @Override
        public boolean equals (Object o) {
            return (o instanceof ODPosition) && 
                ((ODPosition) o).getOffset() == offset;
        }
    }
    
    final class ODEndPosition implements Position {
        public int getOffset() {
            return getLines().getCharCount() + inBuffer.length();
        }
        
        private Document doc() {
            return OutputDocument.this;
        }
        
        @Override
        public boolean equals (Object o) {
            return (o instanceof ODEndPosition) && ((ODEndPosition) o).doc() == 
                doc();
        }
        
        @Override
        public int hashCode() {
            return -2390481;
        }
    }
    
    final class ODStartPosition implements Position {
        public int getOffset() {
            return 0;
        }
        
        private Document doc() {
            return OutputDocument.this;
        }
        
        @Override
        public boolean equals (Object o) {
            return (o instanceof ODStartPosition) && ((ODStartPosition) o).doc() == 
                doc();
        }
        
        @Override
        public int hashCode() {
            return 2190481;
        }
    }
    
    final class ODElement implements Element {
        private int lineIndex;
        private int startOffset = -1;
        private int endOffset = -1;
        ODElement (int lineIndex) {
            this.lineIndex = lineIndex;
        }
        
        @Override
        public int hashCode() {
            return lineIndex;
        }
        
        @Override
        public boolean equals (Object o) {
            return (o instanceof ODElement) && ((ODElement) o).lineIndex == lineIndex &&
                ((ODElement) o).getDocument() == getDocument();
        }
        
        public AttributeSet getAttributes() {
            return SimpleAttributeSet.EMPTY;
        }
        
        public Document getDocument() {
            return OutputDocument.this;
        }
        
        public Element getElement(int param) {
            return null;
        }
        
        public int getElementCount() {
            return 0;
        }
        
        public int getElementIndex(int param) {
            return -1;
        }
        
        public int getEndOffset() {
            calc();
            return endOffset;
        }
        
        public String getName() {
            return null;
        }
        
        public Element getParentElement() {
            return OutputDocument.this;
        }
        
        public int getStartOffset() {
            calc();
            return startOffset;
        }
        
        void calc() {
            synchronized (getLines().readLock()) {
                if (lineIndex < 0) {
                    return;
                }
                if (startOffset == -1) {
                    if (lineIndex >= getLines().getLineCount()) {
                        // line has been removed, probably due to reached limit
                        startOffset = 0;
                        endOffset = 0;
                        return;
                    }
                    startOffset = getLines().getLineStart(lineIndex);
                    if (lineIndex >= getLines().getLineCount()-1) {
                        endOffset = getLines().getCharCount() + inBuffer.length() + 1;
                    } else {
                        endOffset = getLines().getLineStart(lineIndex+1);
                    }
                    assert endOffset >= startOffset : "Illogical getLine #" + lineIndex
                        + ", startOffset=" + startOffset + ", endOffset=" + endOffset
                        + ", charCount=" + getLines().getCharCount()
                        + " with lines " + getLines() + " or writer has been reset"
                        + ". writer: " + (writer == null ? "is null" : 
                            ("writer.isDisposed(): " + writer.isDisposed()
                            + ". writer.getStorage(): " + writer.getStorage()));
                } else if (lineIndex >= getLines().getLineCount()-1) {
                    //always recalculate the last line...
                    endOffset = getLines().getCharCount() + inBuffer.length() + 1;
                }
            }
        }
        
        public boolean isLeaf() {
            return true;
        }
        
        @Override
        public String toString() {
            try {
                return OutputDocument.this.getText(getStartOffset(), getEndOffset() 
                    - getStartOffset());
            } catch (BadLocationException ble) {
                Exceptions.printStackTrace(ble);
                return "";
            }
        }
    }

    public class DO implements DocumentEvent, DocumentEvent.ElementChange {
        private int offset = -1;
        private int length = -1;
        private int lineCount = -1;
        private boolean consumed = false;
        private int first = -1;
        DO(int start) {
            this.first = start;
            if (start < 0) {
                throw new IllegalArgumentException ("Illogical start: " + start);
            }
        }
        
        private void calc() {
            //#60414 related assertion. The exceptions in the bug can only happen
            // when this method is called from 2 threads? but that should not be happening
            assert SwingUtilities.isEventDispatchThread() : "Should be accessed from AWT only or we have a synchronization problem"; //NOI18N
            if (!consumed) {
                consumed = true;

                // update lastFired info
                synchronized (getLines().readLock()) {
                    lastFiredLineCount = getLines().getLineCount();
                    lastFiredLength = getLines().getCharCount() + inBuffer.length();

                    // fill event info
                    if (first < lastFiredLineCount) {
                        offset = getLines().getLineStart(first);
                        lineCount = lastFiredLineCount - first;
                        length = lastFiredLength - offset;
                    } else {
                        lineCount = 0;
                        length = 0;
                        offset = 0;
                    }
                }
            }
        }
        
        public boolean isConsumed() {
            return consumed;
        }
        
        @Override
        public String toString() {
            boolean wasConsumed = isConsumed();
            calc();
            return "Event: first=" + first + " linecount=" + lineCount + " offset=" + offset + " length=" + length + " consumed=" + wasConsumed;
        }
        
        public DocumentEvent.ElementChange getChange(Element element) {
            if (element == OutputDocument.this) {
                return this;
            } else {
                return null;
            }
        }
        
        public Document getDocument() {
            return OutputDocument.this;
        }
        
        public int getLength() {
            calc();
            return length;
        }
        
        public int getOffset() {
            calc();
            return offset;
        }
        
        public DocumentEvent.EventType getType() {
            return first == 0 ? DocumentEvent.EventType.CHANGE : 
                DocumentEvent.EventType.INSERT;
        }
        
        public Element[] getChildrenAdded() {
            calc();
            if (first + lineCount > getLines().getLineCount()) {
                 LOG.log(Level.INFO, "Document line count: {0}, OD line count: {1}",
                         new Object[]{getLines().getLineCount(), first + lineCount});
                 return new Element[0];
            }
            Element[] e = new Element[lineCount];
            for (int i = 0; i < lineCount; i++) {
                e[i] = new ODElement(first + i);
            }
            return e;
        }
        
        public Element[] getChildrenRemoved() {
            return new Element[0];
        }
        
        public Element getElement() {
            return OutputDocument.this;
        }
        
        public int getIndex() {
            calc();
            return first;
        }
    }
    
    @Override
    public String toString() {
        return "OD@" + System.identityHashCode(this) + " for " + getLines().readLock();
    }
}
