/*
 * 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.modules.web.webkit.tooling.console;

import java.awt.Color;
import java.awt.EventQueue;
import java.awt.SystemColor;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.InvocationTargetException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.UIManager;
import org.netbeans.api.project.Project;
import org.netbeans.api.project.ProjectUtils;
import org.netbeans.api.project.SourceGroup;
import org.netbeans.api.project.Sources;
import org.netbeans.modules.web.clientproject.api.WebClientProjectConstants;
import org.netbeans.modules.web.common.api.RemoteFileCache;
import org.netbeans.modules.web.common.api.ServerURLMapping;
import org.netbeans.modules.web.common.api.WebUtils;
import org.netbeans.modules.web.webkit.debugging.api.console.Console;
import org.netbeans.modules.web.webkit.debugging.api.console.ConsoleMessage;
import org.openide.cookies.LineCookie;
import org.openide.filesystems.FileObject;
import org.openide.filesystems.FileUtil;
import org.openide.loaders.DataObject;
import org.openide.loaders.DataObjectNotFoundException;
import org.openide.text.Line;
import org.openide.util.Exceptions;
import org.openide.util.Lookup;
import org.openide.util.NbBundle;
import org.openide.util.RequestProcessor;
import org.openide.util.Utilities;
import org.openide.windows.FoldHandle;
import org.openide.windows.IOColorPrint;
import org.openide.windows.IOColors;
import org.openide.windows.IOFolding;
import org.openide.windows.IOProvider;
import org.openide.windows.InputOutput;
import org.openide.windows.OutputEvent;
import org.openide.windows.OutputListener;
import org.openide.windows.OutputWriter;

/**
 *
 */
public class BrowserConsoleLogger implements Console.Listener {
    
    private static final String LEVEL_ERROR = "error";      // NOI18N
    private static final String LEVEL_WARNING = "warning";      // NOI18N
    private static final String LEVEL_DEBUG = "debug";      // NOI18N
    
    private static final String PROMPT = "> ";              // NOI18N

    private final Lookup projectContext;
    private InputOutput io;
    private Color colorStdBrighter;
    /** The last logged message. */
    private ConsoleMessage lastMessage;
    private Console.InputCallback input;
    private ConsoleReader reader;
    private boolean isFoldingSupported;
    //private Color colorErrBrighter;
    private final AtomicBoolean shownOnError = new AtomicBoolean(false);
    private final RequestProcessor rp = new RequestProcessor(BrowserConsoleLogger.class);

    private static final Logger LOG = Logger.getLogger(BrowserConsoleLogger.class.getName());

    public BrowserConsoleLogger(Lookup projectContext) {
        this.projectContext = projectContext;
        initIO();
    }
    
    @NbBundle.Messages({"BrowserConsoleLoggerTitle=Browser Log"})
    private void initIO() {
        io = IOProvider.getDefault().getIO(Bundle.BrowserConsoleLoggerTitle(), false);
        setIOColors();
        io.setInputVisible(true);
        isFoldingSupported = IOFolding.isSupported(io);
        io.getOut().print(PROMPT);
        Reader r = io.getIn();
        reader = new ConsoleReader(r);
        rp.post(reader);
    }
    
    private void setIOColors() {
        if (IOColors.isSupported(io) && IOColorPrint.isSupported(io)) {
            Color colorStd = IOColors.getColor(io, IOColors.OutputType.OUTPUT);
            //Color colorErr = IOColors.getColor(io, IOColors.OutputType.ERROR);
            Color background = UIManager.getDefaults().getColor("nb.output.background");    // NOI18N
            if (background == null) {
                background = SystemColor.window;
            }
            colorStdBrighter = shiftTowards(colorStd, background);
            //colorErrBrighter = shiftTowards(colorErr, background);
            Color foreground = UIManager.getDefaults().getColor("nb.output.foreground");    // NOI18N
            if (foreground == null) {
                foreground = SystemColor.textText;
            }
            IOColors.setColor(io, IOColors.OutputType.INPUT, shiftTowards(foreground, Color.GREEN));
        }
    }

    public void close() {
        LOG.fine("close() closing IO");
        reader.closing();
        io.getErr().close();
        io.getOut().close();
        try {
            io.getIn().close();
        } catch (IOException ex) {
            Exceptions.printStackTrace(ex);
        }
    }

    private static Color shiftTowards(Color c, Color b) {
        return new Color((c.getRed() + b.getRed())/2, (c.getGreen() + b.getGreen())/2, (c.getBlue() + b.getBlue())/2);
    }
    
    @Override
    public void messageAdded(ConsoleMessage message) {
        try {
            lastMessage = message;
            logMessage(message);
        } catch (IOException ex) {
            Exceptions.printStackTrace(ex);
        }
    }

    @Override
    public void messagesCleared() {
        LOG.fine("messagesCleared() resetting IO");
        try {
            io.getOut().reset();
        } catch (IOException ex) {}
        setIOColors();
        io.setInputVisible(true);
        // HACK: Wait for the processing of reset event
        try {
            EventQueue.invokeAndWait(new Runnable() {
                @Override public void run() {}
            });
        } catch (InterruptedException ex) {
        } catch (InvocationTargetException ex) {}
        io.getOut().print(PROMPT);
    }

    @Override
    public void messageRepeatCountUpdated(int count) {
        LOG.log(Level.FINE, "messageRepeatCountUpdated({0})", count);
        try {
            logMessage(lastMessage);
        } catch (IOException ex) {
            Exceptions.printStackTrace(ex);
        }
    }
    
    private static final SimpleDateFormat formatter = new SimpleDateFormat("HH:mm:ss:SSS");
    public static String getCurrentTime() {
        return formatter.format(new Date(System.currentTimeMillis()));
    }
    
    private boolean shouldLogMessage(ConsoleMessage msg) {
        if (msg == null) {
            return false; // Issue 250389
        }
        String level = msg.getLevel();
        String source = msg.getSource();
        // Ignore CSS warnings
        if (LEVEL_WARNING.equals(level) && "css".equals(source)) {              // NOI18N
            return false;
        }
        return true;
    }
    
    private void logMessage(ConsoleMessage msg) throws IOException {
        if (!shouldLogMessage(msg)) {
            return ;
        }
        io.getOut().print("\b\b");
        Project project = projectContext.lookup(Project.class);
        logMessage(msg, project);
        io.getOut().print(PROMPT);
        boolean isErr = LEVEL_ERROR.equals(msg.getLevel());
        if (io.isClosed() || (isErr && !shownOnError.getAndSet(true))) {
            io.select();
        }
    }
    
    private void logMessage(ConsoleMessage msg, Project project) throws IOException {
        String level = msg.getLevel();
        boolean isErr = LEVEL_ERROR.equals(level);
        String time = getCurrentTime();

        String logInfo = createLogInfo(time, level, msg.getSource(), msg.getType());
        OutputWriter ow = isErr ? io.getErr() : io.getOut();
        String text = msg.getText();
        if (text != null) { // Text is not marked as optional, but apparently, it is.
            String lines[] = text.replace("\r", "").split("\n");
            for (int i = 0; i < lines.length; i++) {
                String singleMessageLine = lines[i];
                if (colorStdBrighter == null && i == lines.length-1) {
                    singleMessageLine += logInfo;
                }
                Object res[] = tryToConvertLineToHyperlink(project, singleMessageLine);
                MyListener l = null;
                String newMessage1 = null;
                String newMessage2 = null;
                if (res != null) {
                    l = (MyListener)res[0];
                    newMessage1 = (String)res[1];
                    newMessage2 = (String)res[2];
                }
                if (l != null && l.isValidHyperlink()) {
                    if (colorStdBrighter != null && i == lines.length-1) {
                        newMessage2 += logInfo;
                    }
                    ow.print(newMessage1);
                    ow.println(newMessage2, l);
                } else {
                    ow.print(singleMessageLine);
                    if (colorStdBrighter != null && i == lines.length-1) {
                        //if (isErr) {
                        //    IOColorPrint.print(io, logInfo, colorErrBrighter);
                        //} else {
                            IOColorPrint.print(io, logInfo, colorStdBrighter);
                        //}
                    } else {
                        ow.println("");
                    }
                }
            }
        }
        
        boolean doPrintStackTrace = LEVEL_ERROR.equals(level) ||
                                    LEVEL_DEBUG.equals(level);
        
        StringBuilder sb;
        boolean first = true;
        if (doPrintStackTrace && msg.getStackTrace() != null) {
            FoldHandle fold = null;
            if (isFoldingSupported) {
                fold = IOFolding.startFold(io, false);
            }
            for (ConsoleMessage.StackFrame sf : msg.getStackTrace()) {
                String indent;
                if (first) {
                    indent = "    at ";
                    first = false;
                } else {
                    indent = "    at ";
                }
                ow.print(indent);
                ow.print(sf.getFunctionName());
                sb = new StringBuilder();
                
                String urlStr = sf.getURLString();
                urlStr = getProjectPath(project, urlStr);
                sb.append(" (").append(urlStr).append(":").append(sf.getLine()).append(":").append(sf.getColumn()).append(")");
                MyListener l = new MyListener(project, sf.getURLString(), sf.getLine(), sf.getColumn());
                if (l.isValidHyperlink()) {
                    ow.println(sb.toString(), l);
                } else {
                    ow.println(sb.toString());
                }
            }
            if (fold != null) {
                fold.finish();
            }
        }
        if (first && msg.getURLString() != null && msg.getURLString().length() > 0) {
            ow.print("  at ");
            String url = msg.getURLString();
            String file = getProjectPath(project, url);
            sb = new StringBuilder(file);
            int line = msg.getLine();
            if (line != -1 && line != 0) {
                sb.append(":");
                sb.append(line);
            }        
            MyListener l = new MyListener(project, url, line, -1);
            if (l.isValidHyperlink()) {
                ow.println(sb.toString(), l);
            } else {
                ow.println(sb.toString());
            }
        }
        List<ConsoleMessage> subMessages = msg.getSubMessages();
        if (!subMessages.isEmpty()) {
            FoldHandle fold = null;
            if (isFoldingSupported) {
                fold = IOFolding.startFold(io, false);
            }
            for (ConsoleMessage cm : subMessages) {
                logMessage(cm, project);
            }
            if (fold != null) {
                fold.finish();
            }
        }
    }

    @NbBundle.Messages({"BrowserConsoleLoggerSessionClosed=Debugging session with browser was closed."})
    void sessionWasClosed() {
        io.getOut().print("\b\b");
        io.getOut().println(Bundle.BrowserConsoleLoggerSessionClosed());
    }

    // XXX: exact this algorithm is also in 
    // javascript.jstestdriver/src/org/netbeans/modules/javascript/jstestdriver/JSTestDriverSupport.java
    // keep them in sync
    private Object[] tryToConvertLineToHyperlink(Project project, String line) {
        // pattern is "at ...... (file:line:column)"
        // file can be also http:// url
        if (!line.endsWith(")")) {
            return tryToConvertLineURLToHyperlink(project, line);
        }
        int start = line.lastIndexOf('(');
        if (start == -1) {
            return null;
        }
        int lineNumberEnd = line.lastIndexOf(':');
        if (lineNumberEnd == -1) {
            return null;
        }
        int fileEnd = line.lastIndexOf(':', lineNumberEnd-1);
        if (fileEnd == -1) {
            return null;
        }
        if (start >= fileEnd) {
            return null;
        }
        int lineNumber = -1;
        int columnNumber = -1;
        try {
            lineNumber = Integer.parseInt(line.substring(fileEnd+1, lineNumberEnd));
            columnNumber = Integer.parseInt(line.substring(lineNumberEnd+1, line.length()-1));
        } catch (NumberFormatException e) {
            //ignore
        }
        if (columnNumber != -1 && lineNumber == -1) {
            // perhaps stack trace had only line number:
            lineNumber = columnNumber;
        }
        if (lineNumber == -1) {
            return null;
        }
        String file = line.substring(start+1, fileEnd);
        if (file.length() == 0) {
            return null;
        }
        String s1 = line.substring(0, start);
        String s2 = "(" +  // NOI18N
                getProjectPath(project, file) +
            line.substring(fileEnd, line.length());
        MyListener l = new MyListener(project, file, lineNumber, columnNumber);
        return new Object[]{l,s1,s2};
    }
    
    private Object[] tryToConvertLineURLToHyperlink(Project project, String line) {
        int u1 = line.indexOf("http://");   // NOI18N
        if (u1 < 0) {
            u1 = line.indexOf("https://");  // NOI18N
        }
        if (u1 < 0) {
            return null;
        }
        int ue = line.indexOf(' ', u1);
        if (ue < 0) {
            ue = line.length();
        }
        int col2 = line.lastIndexOf(':', ue);
        if (col2 < 0) {
            return null;
        }
        int col1 = line.lastIndexOf(':', col2 - 1);
        if (col1 < 0) {
            return null;
        }
        int lineNumber = -1;
        int columnNumber = -1;
        try {
            lineNumber = Integer.parseInt(line.substring(col1+1, col2));
            columnNumber = Integer.parseInt(line.substring(col2+1, ue));
        } catch (NumberFormatException e) {
            //ignore
        }
        if (columnNumber != -1 && lineNumber == -1) {
            // perhaps stack trace had only line number:
            lineNumber = columnNumber;
        }
        if (lineNumber == -1) {
            return null;
        }
        String file = line.substring(u1, col1);
        if (file.length() == 0) {
            return null;
        }
        String s1 = line.substring(0, u1);
        String s2 = line.substring(u1, line.length());
        MyListener l = new MyListener(project, file, lineNumber, columnNumber);
        return new Object[]{l,s1,s2};
    }
    
    
    private static final String LOG_IGNORED = "log";    // NOI18N
    private static final String CONSOLE_API = "console-api";    // NOI18N
    private static final String TIME_SEPARATOR = " | "; // NOI18N
    private static String createLogInfo(String time, String level, String source, String type) {
        //String logInfo = " ("+time+" | "+level+","+msg.getSource()+","+msg.getType()+")\n";
        StringBuilder logInfoBuilder = new StringBuilder(" (");
        logInfoBuilder.append(time);
        boolean separator = false;
        if (!LOG_IGNORED.equals(level) && !level.isEmpty()) {
            separator = true;
            logInfoBuilder.append(TIME_SEPARATOR);
            logInfoBuilder.append(level);
        }
        if (!CONSOLE_API.equals(source) && !source.isEmpty()) {
            if (separator) {
                logInfoBuilder.append(", ");
            } else {
                logInfoBuilder.append(TIME_SEPARATOR);
            }
            logInfoBuilder.append(source);
        }
        if (!LOG_IGNORED.equals(type)) {
            if (separator) {
                logInfoBuilder.append(", ");
            } else {
                logInfoBuilder.append(TIME_SEPARATOR);
            }
            logInfoBuilder.append(type);
        }
        logInfoBuilder.append(")\n");
        return logInfoBuilder.toString();
    }
    
    /**
     * Try to find a more readable project-relative path.<p>
     * E.g.: "http://localhost:89/SimpleLiveHTMLTest/js/app.js:8:9"
     * is turned into: "js/app.js:8:9"
     * @param urlStr The URL
     * @return a project-relative path, or the original URL.
     */
    public static String getProjectPath(Project project, String urlStr) {
        try {
            URL url = new URL(urlStr);
            if (project != null) {
                FileObject fo = ServerURLMapping.fromServer(project, url);
                if (fo != null) {
                    String relPath = FileUtil.getRelativePath(project.getProjectDirectory(), fo);
                    if (relPath != null) {
                        urlStr = relPath;
                    }
                }
            }
        } catch (MalformedURLException murl) {}
        return urlStr;
    }

    void setInput(Console.InputCallback input) {
        this.input = input;
    }

    public static class MyListener implements OutputListener {

        private final String url;
        private final int line;
        private final int column;
        private final Project project;

        public MyListener(Project project, String url, int line, int column) {
            this.url = url;
            this.line = line;
            this.column = column;
            this.project = project;
        }
        
        @Override
        public void outputLineSelected(OutputEvent ev) {
        }

        @Override
        public void outputLineAction(OutputEvent ev) {
            Line l = getLine();
            if (l != null) {
                l.show(Line.ShowOpenType.OPEN, 
                    Line.ShowVisibilityType.FOCUS, column != -1 ? column -1 : -1);
            }
        }
        private Line getLine() {
            return BrowserConsoleLogger.getLine(project, url, line-1);
        }

        @Override
        public void outputLineCleared(OutputEvent ev) {
        }
        
        public boolean isValidHyperlink() {
            return getLine() != null;
        }
    
    }

    private static Line getLine(Project project, final String filePath, final int lineNumber) {
        if (filePath == null || lineNumber < 0) {
            return null;
        }

        FileObject fileObject = null;
        try {
            if (filePath.startsWith("http:") || filePath.startsWith("https:")) {    // NOI18N
                URL url;
                try {
                    url = new URI(filePath).toURL();
                } catch (URISyntaxException usex) {
                    // Issue 231564
                    url = new URL(filePath);
                }
                if (project != null) {
                    fileObject = ServerURLMapping.fromServer(project, url);
                }
                if (fileObject == null) {
                    fileObject = RemoteFileCache.getRemoteFile(url);
                }
            } else {
                File file;
                if (filePath.startsWith("file:/")) {                                // NOI18N
                    URI uri;
                    try {
                        // Issue 255536
                        uri = new URI(filePath);
                    } catch (URISyntaxException usex) {
                        uri = null;
                    }
                    if (uri == null || uri.getQuery() != null || uri.getFragment() != null) {
                        // Remove the query and/or fragment
                        uri = WebUtils.stringToUrl(WebUtils.urlToString(new URL(filePath), true)).toURI();
                    }
                    file = Utilities.toFile(uri);
                } else {
                    file = new File(filePath);
                    if (!file.isAbsolute() && project != null) {
                        fileObject = findFileInProjecSources(project, filePath);
                    }
                }
                if (fileObject == null) {
                    fileObject = FileUtil.toFileObject(FileUtil.normalizeFile(file));
                }
            }
        } catch (IOException | URISyntaxException ex) {
            Exceptions.printStackTrace(ex);
        }
        if (fileObject == null) {
            LOG.log(Level.FINE, "Cannot resolve \"{0}\"", filePath);
            return null;
        }

        LineCookie lineCookie = getLineCookie(fileObject);
        if (lineCookie == null) {
            LOG.log(Level.INFO, "No line cookie for \"{0}\"", fileObject);
            return null;
        }
        try {
            return lineCookie.getLineSet().getCurrent(lineNumber);
        } catch (IndexOutOfBoundsException ioob) {
            List<? extends Line> lines = lineCookie.getLineSet().getLines();
            if (lines.size() > 0) {
                return lines.get(lines.size() - 1);
            } else {
                return null;
            }
        }
    }
    
    private static final String[] KNOWN_SOURCE_TYPES = {Sources.TYPE_GENERIC,
                                                        WebClientProjectConstants.SOURCES_TYPE_HTML5_SITE_ROOT,
                                                        WebClientProjectConstants.SOURCES_TYPE_HTML5_TEST,
                                                        WebClientProjectConstants.SOURCES_TYPE_HTML5_TEST_SELENIUM };
    private static FileObject findFileInProjecSources(Project project, String filePath) {
        Sources sources = ProjectUtils.getSources(project);
        for (String sourceType : KNOWN_SOURCE_TYPES) {
            SourceGroup[] sourceGroups = sources.getSourceGroups(sourceType);
            for (SourceGroup sourceGroup : sourceGroups) {
                FileObject rootFolder = sourceGroup.getRootFolder();
                FileObject fileObject = rootFolder.getFileObject(filePath);
                if (fileObject != null) {
                    return fileObject;
                }
            }
        }
        return null;
    }

    public static LineCookie getLineCookie(final FileObject fo) {
        LineCookie result = null;
        try {
            DataObject dataObject = DataObject.find(fo);
            if (dataObject != null) {
                result = dataObject.getLookup().lookup(LineCookie.class);
            }
        } catch (DataObjectNotFoundException e) {
            Exceptions.printStackTrace(Exceptions.attachSeverity(e, Level.INFO));
        }
        return result;
    }
    
    private class ConsoleReader implements Runnable {
        
        private final BufferedReader r;
        private volatile boolean closing = false;
        
        public ConsoleReader(Reader r) {
            this.r = new BufferedReader(r);
        }
        
        void closing() {
            closing = true;
        }

        @Override
        public void run() {
            try {
                String line;
                while (!closing) {
                    line = r.readLine();
                    LOG.log(Level.FINE, "Got line from Console Reader: \"{0}\"", line);
                    if (line != null) {
                        input.line(line);
                    }
                }
            } catch (IOException ex) {
                if (!closing) {
                    Exceptions.printStackTrace(ex);
                }
            }
            LOG.log(Level.FINE, "ConsoleReader has finished. Bye.");
        }
        
    }
}
