/*
 * 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.Color;
import org.openide.util.NbBundle;
import org.openide.windows.OutputListener;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.ClosedByInterruptException;
import java.util.Collection;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.openide.util.Exceptions;
import org.openide.util.Pair;
import org.openide.util.Utilities;

/**
 * Implementation of OutputWriter backed by an implementation of Storage (memory mapped file, heap array, etc).
 *
 * @author  Tim Boudreau
 */
class OutWriter extends PrintWriter {
    private static final Logger LOG =
            Logger.getLogger(OutWriter.class.getName());
    /** A flag indicating an io exception occurred */
    private boolean trouble = false;

    private NbIO owner;
    
    private boolean disposed = false;

    private boolean disposeOnClose = false;

    //IZ 44375 - Memory mapping fails with bad file handle on win 98
    private static final boolean USE_HEAP_STORAGE =
        Boolean.getBoolean("nb.output.heap") || Utilities.getOperatingSystem() == //NOI18N
        Utilities.OS_WIN98 || 
        Utilities.getOperatingSystem() == Utilities.OS_WIN95;

    /**
     * Byte array used to write the line separator after line writes.
     */
    static final String LINE_SEPARATOR = "\n";

    /** The read-write backing storage.  May be heap or */
    private Storage storage;
    
    /** The Lines object that will be used for reading data out of the 
     * storage */
    private AbstractLines lines = new LinesImpl();
    
    /** Flag set if a write failed due to disk space limits.  Subsequent
     * instances will use HeapStorage in this case */
    static boolean lowDiskSpace = false;

    
    /**
     * Need to remember the line start and lenght over multiple calls to
     * write(ByteBuffer), needed to facilitate other calls than println()
     */
    private int lineStart;
    private int lineLength;
    private int lineCharLengthWithTabs;

    /** Creates a new instance of OutWriter */
    OutWriter(NbIO owner) {
        this();
        this.owner = owner;
    }

    /**
     * Package constructor for unit tests
     */
    OutWriter() {
        super (new DummyWriter());
        lineStart = -1;
        lineLength = 0;
        lineCharLengthWithTabs = 0;
    }

    Storage getStorage() {
        if (disposed) {
            throw new IllegalStateException ("Output file has been disposed!");
        }
        if (storage == null) {
            storage = USE_HEAP_STORAGE || lowDiskSpace ? 
                new HeapStorage() : new FileMapStorage();
        }
        return storage;
    }
    
    boolean hasStorage() {
        return storage != null;
    }
    
    boolean isDisposed() {
        return disposed;
    }
    
    boolean isEmpty() {
        return storage == null ? true : storage.size() == 0;
    }

    @Override
    public String toString() {
        return "OutWriter@" + System.identityHashCode(this) + " for " + owner + " closed ";
    }

    private int errorCount = 0;

    /** Generic exception handling, marking the error flag and notifying it with ErrorManager */
    private void handleException(Exception e) {
        setError();
        if (Controller.LOG) {
            StackTraceElement[] el = e.getStackTrace();
            Controller.log("EXCEPTION: " + e.getClass() + e.getMessage());
            for (int i = 1; i < el.length; i++) {
                Controller.log(el[i].toString());
            }
        }
        if (errorCount++ < 3) {
            Exceptions.printStackTrace(e);
        }
    }

    /**
     * Write the passed buffer to the backing storage, recording the line start in the mapping of lines to
     * byte offsets.
     *
     * @param bb
     * @throws IOException
     */
    private synchronized void write(ByteBuffer bb, int lineCharLengthWithTabs, boolean completeLine) {
        if (checkError()) {
            return;
        }
        closed = false;
        int start = -1;
        try {
            start = getStorage().write(bb);
        } catch (ClosedByInterruptException cbiex) {
            onWriteException();
        } catch (java.nio.channels.AsynchronousCloseException ace) {
            //Execution termination has sent ThreadDeath to the process in the
            //middle of a write
            Exceptions.printStackTrace(ace);
            onWriteException();
        } catch (IOException ioe) {
            //Out of disk space
            if (ioe.getMessage().indexOf("There is not enough space on the disk") != -1) { //NOI18N
                lowDiskSpace = true;
                String msg = NbBundle.getMessage(OutWriter.class, 
                    "MSG_DiskSpace", storage); //NOI18N
                Exceptions.attachLocalizedMessage(ioe, msg);
                Exceptions.printStackTrace(ioe);
                setError();
                storage.dispose();
            } else {
                //Existing output may still be readable - close, but leave
                //open for reads - if there's a problem there too, the error
                //flag will be set when a read is attempted
                Exceptions.printStackTrace(ioe);
                onWriteException();
            }
        }
        if (checkError()) {
            return;
        }
        int length = bb.limit();
        lineLength = lineLength + length;
        this.lineCharLengthWithTabs += lineCharLengthWithTabs;
        if (start >= 0 && lineStart == -1) {
            lineStart = start;
        }
        lines.lineUpdated(lineStart, lineLength, this.lineCharLengthWithTabs, completeLine);
        if (completeLine) {
            lineStart = -1;
            lineLength = 0;
            this.lineCharLengthWithTabs = 0;
        }
        if (owner != null && owner.isStreamClosed()) {
            owner.setStreamClosed(false);
            lines.fire();
        }
    }

    /**
     * An exception has occurred while writing, which has left us in a readable state, but not
     * a writable one.  Typically this happens when an executing process was sent Thread.stop()
     * in the middle of a write.
     */
    void onWriteException() {
        trouble = true;
        if (Controller.LOG) Controller.log (this + " Close due to termination");
        ErrWriter err = owner.writer().err();
        if (err != null) {
            err.closed=true;
        }
        owner.setStreamClosed(true);
        close();
    }

    /**
     * Dispose this writer.  If reuse is true, the underlying storage will be disposed, but the
     * OutWriter will still be usable.  If reuse if false, note that any current ChangeListener is cleared.
     *
     */
    public synchronized void dispose() {
        if (disposed) {
            //This can happen if a tab was closed, so we were already disposed, but then the
            //ant module tries to reuse the tab -
            return;
        }
        if (Controller.LOG) Controller.log (this + ": OutWriter.dispose - owner is " + (owner == null ? "null" : owner.getName()));
        clearListeners();
        if (storage != null) {
            lines.onDispose(storage.size());
            storage.dispose();
            storage = null;
        }
        if (Controller.LOG) Controller.log (this + ": Setting owner to null, trouble to true, dirty to false.  This OutWriter is officially dead.");
        owner = null;
        disposed = true;
    }


    private void clearListeners() {
        if (Controller.LOG) Controller.log (this + ": Sending outputLineCleared to all listeners");
        if (owner == null) {
            //Somebody called reset() twice
            return;
        }
        synchronized (this) {
            if (lines.hasListeners()) {
                int[] listenerLines = lines.getLinesWithListeners();
                Controller.ControllerOutputEvent e = new Controller.ControllerOutputEvent(owner, 0);
                for (int i=0; i < listenerLines.length; i++) {
                    Collection<OutputListener> ols = lines.getListenersForLine(listenerLines[i]);
                    e.setLine(listenerLines[i]);
                    for (OutputListener ol : ols) {
                        ol.outputLineCleared(e);
                    }
                }
            } else {
                if (Controller.LOG) Controller.log (this + ": No listeners to clear");
            }
        }
    }

    public synchronized boolean isClosed() {
        if (checkError() || disposed || (storage != null && storage.isClosed())) {
            return true;
        } else {
            return closed;
        }
    }

    public Lines getLines() {
        return lines;
    }

    private boolean closed = false;
    @Override
    public synchronized void close() {
        closed = true;
        try {
            //#49955 - possible (but difficult) to end up with close()
            //called twice
            if (storage != null) {
                storage.close();
            }
            lines.fire();
            if (isDisposeOnClose() && !isDisposed()) {
                dispose();
            }
        } catch (IOException ioe) {
            onWriteException();
        }
    }

    /**
     * Is this writer set to dispose automatically when it is closed?
     */
    boolean isDisposeOnClose() {
        return disposeOnClose;
    }

    /**
     * Set the writer to dispose or not to dispose itself automatically when it
     * is closed.
     */
    void setDisposeOnClose(boolean disposeOnClose) {
        this.disposeOnClose = disposeOnClose;
    }

    @Override
    public synchronized void println(String s) {
        doWrite(s, null, 0, s.length());
        println();
    }

    @Override
    public synchronized void flush() {
        if (checkError()) {
            return;
        }
        try {
            getStorage().flush();
            lines.fire();
        } catch (IOException e) {
            onWriteException();
        }
    }

    @Override
    public boolean checkError() {
        return disposed || trouble;
    }

    static class CharArrayWrapper implements CharSequence {

        private char[] arr;
        private int off;
        private int len;

        public CharArrayWrapper(char[] arr) {
            this(arr, 0, arr.length);
        }

        public CharArrayWrapper(char[] arr, int off, int len) {
            this.arr = arr;
            this.off = off;
            this.len = len;
        }

        public char charAt(int index) {
            return arr[off + index];
        }

        public int length() {
            return len;
        }

        public CharSequence subSequence(int start, int end) {
            return new CharArrayWrapper(arr, off + start, end - start);
        }

        @Override
        public String toString() {
            return new String(arr, off, len);
        }
    }

    @Override
    public synchronized void write(int c) {
        doWrite(new String(new char[]{(char)c}), null, 0, 1);
        checkLimits();
    }

    private void checkLimits() {
        int shift = lines.checkLimits();
        if (lineStart > 0) {
            lineStart -= shift;
        }
    }
    
    @Override
    public synchronized void write(char data[], int off, int len) {
        doWrite(new CharArrayWrapper(data), null, off, len);
        checkLimits();
    }
    
    /** write buffer size in chars */
    private static final int WRITE_BUFF_SIZE = 16*1024;
    private synchronized void doWrite(CharSequence s, OutputListener l, int off, int len) {
        if (checkError() || len == 0) {
            return;
        }
        
        // XXX will not pick up ANSI sequences broken across write blocks, but this is likely rare
        if (printANSI(s.subSequence(off, off + len), l, false, OutputKind.OUT, false)) {
            return;
        }
        /* XXX causes stack overflow
        if (ansiColor != null) {
            print(s, null, false, null, false, false);
            return;
        }
         */

        int lineCLVT = 0; // Character Lenght With Tabs
        try {
            boolean written = false;
            char lastChar = 0;
            ByteBuffer byteBuff = getStorage().getWriteBuffer(WRITE_BUFF_SIZE * 2);
            CharBuffer charBuff = byteBuff.asCharBuffer();
            int charOffset = AbstractLines.toCharIndex(getStorage().size());
            int skipped = 0;
            int tabLength = 0; // last tab length
            for (int i = off; i < off + len; i++) {
                if (charBuff.position() + 1 >= WRITE_BUFF_SIZE) {
                    write((ByteBuffer) byteBuff.position(charBuff.position() * 2), lineCLVT, false);
                    written = true;
                }
                if (written) {
                    byteBuff = getStorage().getWriteBuffer(WRITE_BUFF_SIZE * 2);
                    charBuff = byteBuff.asCharBuffer();
                    lineCLVT = 0;
                    written = false;
                }
                char c = s.charAt(i);
                if (lastChar == '\r' && c != '\n') { // \r without following \n
                    int p;
                    for (p = charBuff.position(); p > 0; p--) {
                        char charP1 = charBuff.get(p - 1);
                        if (charP1 == '\n') {
                            break;
                        }
                        if (charP1 == '\t') {
                            lineCLVT -= lines.removeLastTab();
                        }
                        skipped++;
                        charBuff.position(p - 1);
                    }
                    if (p == 0) {
                        clearLine();
                    }
                }
                if (c == '\t') {
                    charBuff.put(c);
                    tabLength = WrappedTextView.TAB_SIZE - ((this.lineCharLengthWithTabs + lineCLVT) % WrappedTextView.TAB_SIZE);
                    LOG.log(Level.FINEST, "Going to add tab: charOffset = {0}, i = {1}, off = {2}," //NOI18N
                            + "tabLength = {3}, tabIndex = {4}, skipped = {5}", //NOI18N
                            new Object[]{charOffset, i, off, tabLength, charOffset + (i - off), skipped}); // #201450
                    lines.addTabAt(charOffset + (i - off) - skipped, tabLength);
                    lineCLVT += tabLength;
                } else if (c == '\b') {
                    int skip = handleBackspace(charBuff);
                    if (skip == -2) {
                        lineCLVT -= lines.removeLastTab();;
                    } else if (skip == 2) {
                        lineCLVT--;
                    } else if (skip == 1) {
                        Pair<Integer, Integer> removedInfo;
                        removedInfo = lines.removeCharsFromLastLine(1);
                        storage.removeBytesFromEnd(removedInfo.first() * 2);
                        lineLength -= removedInfo.first() * 2;
                        lineCharLengthWithTabs -= removedInfo.second();
                    }
                    skipped += Math.abs(skip);
                } else if (c == '\n') {
                    charBuff.put('\n');
                    int pos = charBuff.position() * 2;
                    ByteBuffer bf = (ByteBuffer) byteBuff.position(pos);
                    write(bf, lineCLVT, true);
                    written = true;
                } else if (c != '\r') {
                    charBuff.put(c);
                    lineCLVT++;
                } else {
                    assert c == '\r';
                    skipped++;
                }
                lastChar = c;
            }
            if (!written) {
                write((ByteBuffer) byteBuff.position(charBuff.position() * 2), lineCLVT, false);
            }
        } catch (IOException ioe) {
            onWriteException();
        } catch (RuntimeException ex) {
            LOG.log(Level.INFO, "Cannot write text: off={0}, "          //NOI18N
                    + "len={1}, lineStart={2}",                         //NOI18N
                    new Object[]{off, len, lineStart});
            throw ex;
        }
        lines.delayedFire();
        return;
    }

    /**
     * Update state of character buffer after a backspace character has been
     * read.
     *
     * @return Value -2, 1 or 2: number of character that will be skipped (has
     * no corresponding visible character) in the resulting output. In standard
     * case, it is 2 (the last character + the \b character), if the buffer is
     * empty, it is 1 (only the \b character). If tab character was deleted,
     * return -2.
     */
    private int handleBackspace(CharBuffer charBuff) {
        if (charBuff.position() > 0) {
            char deletedChar = charBuff.get(charBuff.position() - 1);
            charBuff.position(charBuff.position() - 1);
            return deletedChar == '\t' ? -2 : 2;
        } else {
            return 1;
        }
    }

    @Override
    public synchronized void write(char data[]) {
        doWrite(new CharArrayWrapper(data), null, 0, data.length);
        checkLimits();
    }

    @Override
    public synchronized void println() {
        printLineEnd();
        checkLimits();
    }

    private void printLineEnd() {
        doWrite("\n", null, 0, 1);
    }

    /**
     * Write a portion of a string.
     * @param s A String
     * @param off Offset from which to start writing characters
     * @param len Number of characters to write
     */
    @Override
    public synchronized void write(String s, int off, int len) {
        doWrite(s, null, off, len);
        checkLimits();
    }

    @Override
    public synchronized void write(String s) {
        doWrite(s, null, 0, s.length());
        checkLimits();
    }

    public synchronized void println(String s, OutputListener l) {
        println(s, l, false);
    }

    public synchronized void println(String s, OutputListener l, boolean important) {
        print(s, l, important, null, null, OutputKind.OUT, true);
    }

    synchronized void print(CharSequence s, OutputListener l, boolean important, Color c, Color b, OutputKind outKind, boolean addLS) {
        if (c == null) {
            if (l == null && printANSI(s, null, important, outKind, addLS)) {
                return;
            }
            c = ansiColor; // carry over from previous line
        }
        int lastLine = lines.getLineCount() - 1;
        int lastPos = lines.getCharCount();
        doWrite(s, l, 0, s.length());
        if (addLS) {
            printLineEnd();
        }
        lines.updateLinesInfo(s, lastLine, lastPos, l, important, outKind, c, b);
        checkLimits();
    }
    private Color ansiColor;
    private Color ansiBackground;
    private int ansiColorCode;
    private int ansiBackgroundCode = 9;
    private boolean ansiBright;
    private boolean ansiFaint;
    private static final Pattern ANSI_CSI = Pattern.compile("\u001B\\[(\\d+(;\\d+)*)?(\\p{Alpha})"); // XXX or x9B for single-char CSI?
    private static final Color[] COLORS = { // xterm from http://en.wikipedia.org/wiki/ANSI_escape_code#Colors
        null, // default color (black for stdout)
        new Color(205, 0, 0),
        new Color(0, 205, 0),
        new Color(205, 205, 0),
        new Color(0, 0, 238),
        new Color(205, 0, 205),
        new Color(0, 205, 205),
        new Color(229, 229, 229),
        // bright variants:
        new Color(127, 127, 127),
        new Color(255, 0, 0),
        new Color(0, 255, 0),
        new Color(255, 255, 0),
        new Color(92, 92, 255),
        new Color(255, 0, 255),
        new Color(0, 255, 255),
        new Color(255, 255, 255),
    };
    private boolean printANSI(CharSequence s, OutputListener l, boolean important, OutputKind outKind, boolean addLS) { // #192779
        int len = s.length();
        boolean hasEscape = false; // fast initial check
        for (int i = 0; i < len - 1; i++) {
            if (s.charAt(i) == '\u001B' && s.charAt(i + 1) == '[') { // XXX or x9B for single-char CSI?
                hasEscape = true;
                break;
            }
        }
        if (!hasEscape) {
            return false;
        }
        Matcher m = ANSI_CSI.matcher(s);
        int text = 0;
        while (m.find()) {
            int esc = m.start();
            if (esc > text) {
                print(s.subSequence(text, esc), l, important, ansiColor, ansiBackground, outKind, false);
            }
            text = m.end();
            if ("K".equals(m.group(3)) && "2".equals(m.group(1))) {     //NOI18N
                clearLine();
                continue;
            } else if (!"m".equals(m.group(3))) {                       //NOI18N
                continue; // not a SGR ANSI sequence
            }
            String paramsS = m.group(1);
            if (Controller.VERBOSE) {
                Controller.log("ANSI CSI+SGR: " + paramsS);
            }
            if (paramsS == null) { // like ["0"]
                ansiColorCode = 0;
                ansiBackgroundCode = 9;
                ansiBright = false;
                ansiFaint = false;
            } else {
                for (String param : paramsS.split(";")) {
                    int code = Integer.parseInt(param);
                    if (code == 0) { // Reset / Normal
                        ansiColorCode = 0;
                        ansiBackgroundCode = 9; // default
                        ansiBright = false;
                        ansiFaint = false;
                    } else if (code == 1) { // Bright (increased intensity) or Bold
                        ansiBright = true;
                        ansiFaint = false;
                    } else if (code == 2) { // Faint (decreased intensity)
                        ansiBright = false;
                        ansiFaint = true;
                    } else if (code == 21) { // Bright/Bold: off or Underline: Double
                        ansiBright = false;
                    } else if (code == 22) { // Normal color or intensity
                        ansiBright = false;
                        ansiFaint = false;
                    } else if (code >= 30 && code <= 37) { // Set text color
                        ansiColorCode = code - 30;
                    } else if (code == 39) { // Default text color
                        ansiColorCode = 0;
                    } else if (code >= 40 && code <= 47) { // Set background
                        ansiBackgroundCode = code - 40;
                    } else if (code == 49) { // Set background
                        ansiBackgroundCode = 9; // default color
                    }
                }
            }
            assert ansiColorCode >= 0 && ansiColorCode <= 7;
            assert ansiBackgroundCode >= 0 && ansiBackgroundCode <= 9;
            assert !(ansiBright && ansiFaint);
            Color setColor = COLORS[ansiColorCode + (ansiBright ? 8 : 0)];
            ansiBackground = ansiBackgroundCode == 9 ? null
                    : COLORS[ansiBackgroundCode];
            ansiColor = fixTextColor(setColor, ansiBackground);
            if (ansiFaint && ansiColor != null) {
                ansiColor = ansiColor.darker();
            }
        }
        if (text == 0) {
            // That was an unknown sequence
            return false;
        }
        if (text < len) { // final segment
            print(s.subSequence(text, len), l, important, ansiColor, ansiBackground, outKind, addLS);
        } else if (addLS) { // line ended w/ control seq
            printLineEnd();
        }
        return true;
    }

    /**
     * Clears the current line. Called when ANSI sequence "\u001B[2K" is
     * detected, or a CR (\r) character not followed by LN (\n) is reached.
     */
    private void clearLine() {
        //NOI18N
        Pair<Integer, Integer> r = lines.removeCharsFromLastLine(-1);
        try {
            getStorage().removeBytesFromEnd(r.first() * 2);
        } catch (IOException ex) {
            Exceptions.printStackTrace(ex);
        }
        lineLength -= r.first() * 2;
        lineCharLengthWithTabs -= r.second();
    }

    synchronized void print(CharSequence s, LineInfo info, boolean important) {
        int line = lines.getLineCount() - 1;
        doWrite(s, null, 0, s.length());
        if (info != null) {
            lines.addLineInfo(line, info, important);
        }
        checkLimits();
    }

    /**
     * A useless writer object to pass to the superclass constructor.  We override all methods
     * of it anyway.
     */
    static class DummyWriter extends Writer {
        
        DummyWriter() {
            super (new Object());
        }
        
        public void close() throws IOException {
        }
        
        public void flush() throws IOException {
        }
        
        public void write(char[] cbuf, int off, int len) throws IOException {
        }
    }

    private class LinesImpl extends AbstractLines {
        LinesImpl() {
            super();
        }

        protected Storage getStorage() {
            return OutWriter.this.getStorage();
        }

        protected boolean isDisposed() {
             return OutWriter.this.disposed;
        }

        public Object readLock() {
            return OutWriter.this;
        }

        public boolean isGrowing() {
            return !isClosed();
        }

        protected void handleException (Exception e) {
            OutWriter.this.handleException(e);
        }
    }

    /**
     * Fix foreground color if it is not readable on the background.
     */
    private static Color fixTextColor(Color textColor, Color background) {
        if (background == null && textColor != null
                && textColor.equals(Color.WHITE)) {
            return Color.BLACK;
        } else if (textColor != null && background != null
                && colorDiff(textColor, background) < 10) {
            if (colorDiff(textColor, Color.WHITE)
                    > colorDiff(textColor, Color.BLACK)) {
                return Color.WHITE;
            } else {
                return Color.BLACK;
            }
        } else {
            return textColor;
        }
    }

    /**
     * Difference of two colors. The bigger number, the more different the two
     * colors are.
     */
    private static int colorDiff(Color c1, Color c2) {
        int redDiff = Math.abs(c1.getRed() - c2.getRed());
        int greenDiff = Math.abs(c1.getGreen() - c2.getGreen());
        int blueDiff = Math.abs(c1.getBlue() - c2.getBlue());
        return Math.max(redDiff, Math.max(greenDiff, blueDiff));
    }
}
