/*
 * 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.api.java.source;

import com.sun.tools.javac.api.JavacTaskImpl;
import com.sun.tools.javac.util.Log;

import java.io.*;
import java.lang.ref.WeakReference;
import java.nio.charset.Charset;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.BadLocationException;
import javax.swing.text.Document;
import javax.swing.text.Position;
import javax.tools.JavaFileObject;
import org.netbeans.api.annotations.common.CheckForNull;
import org.netbeans.api.annotations.common.NonNull;
import org.netbeans.api.annotations.common.NullUnknown;
import org.netbeans.api.editor.document.AtomicLockDocument;
import org.netbeans.api.editor.document.LineDocumentUtils;
import org.netbeans.api.java.source.ModificationResult.CreateChange;
import org.netbeans.api.queries.FileEncodingQuery;
import org.netbeans.lib.editor.util.swing.DocumentUtilities;
import org.netbeans.modules.java.preprocessorbridge.spi.JavaFileFilterImplementation;
import org.netbeans.modules.java.source.JavaFileFilterQuery;
import org.netbeans.modules.java.source.parsing.JavacParser;
import org.netbeans.modules.java.source.parsing.SourceFileManager;
import org.netbeans.modules.java.source.save.ElementOverlay;
import org.netbeans.modules.parsing.api.Embedding;
import org.netbeans.modules.parsing.api.ParserManager;
import org.netbeans.modules.parsing.api.ResultIterator;
import org.netbeans.modules.parsing.api.Source;
import org.netbeans.modules.parsing.api.UserTask;
import org.netbeans.modules.parsing.impl.Utilities;
import org.netbeans.modules.parsing.impl.indexing.friendapi.IndexingController;
import org.netbeans.modules.parsing.impl.indexing.implspi.ActiveDocumentProvider;
import org.netbeans.modules.parsing.spi.ParseException;
import org.netbeans.modules.parsing.spi.Parser;
import org.openide.filesystems.FileObject;
import org.openide.filesystems.FileUtil;
import org.openide.util.Lookup;
import org.openide.util.NbBundle;
import org.openide.util.Parameters;

/**
 * Class that collects changes built during a modification task run.
 *
 * @author Dusan Balek
 */
public final class ModificationResult {

    private static final Logger LOG = Logger.getLogger(ModificationResult.class.getName());
    
    private boolean committed;
    Map<FileObject, List<Difference>> diffs = new HashMap<FileObject, List<Difference>>();
    Map<?, int[]> tag2Span = new IdentityHashMap<Object, int[]>();
    
    /** Creates a new instance of ModificationResult */
    ModificationResult() {
    }

    private final Throwable creator;
    
    {
        boolean keepStackTrace = false;
        assert keepStackTrace = true;
        creator = keepStackTrace ? new Throwable() : null;
    }
    
    // API of the class --------------------------------------------------------

    /**
     * Runs a task over given sources, the task has an access to the {@link WorkingCopy}
     * using the {@link WorkingCopy#get(org.netbeans.modules.parsing.spi.Parser.Result)} method.
     * @param sources on which the given task will be performed
     * @param task to be performed
     * @return the {@link ModificationResult}
     * @throws org.netbeans.modules.parsing.spi.ParseException
     * @since 0.42
     */
    public static @NonNull ModificationResult runModificationTask(final @NonNull Collection<Source> sources, final @NonNull UserTask task) throws ParseException {
        final ModificationResult result = new ModificationResult();
        final ElementOverlay overlay = ElementOverlay.getOrCreateOverlay();
        ParserManager.parse(sources, new UserTask() {
            @Override
            public void run(ResultIterator resultIterator) throws Exception {
                resultIterator = JavacParser.MIME_TYPE.equals(resultIterator.getSnapshot().getMimeType()) ? resultIterator : findEmbeddedJava(resultIterator);
                if (resultIterator != null) {
                    Parser.Result parserResult = resultIterator.getParserResult();
                    final CompilationController cc = CompilationController.get(parserResult);
                    assert cc != null;
                    final WorkingCopy copy = new WorkingCopy (cc.impl, overlay);
                    assert WorkingCopy.instance == null;
                    WorkingCopy.instance = new WeakReference<WorkingCopy>(copy);
                    try {
                        task.run(resultIterator);
                    } finally {
                        WorkingCopy.instance = null;
                    }
                    final JavacTaskImpl jt = copy.impl.getJavacTask();
                    Log.instance(jt.getContext()).nerrors = 0;
                    final List<ModificationResult.Difference> diffs = copy.getChanges(result.tag2Span);
                    if (diffs != null && diffs.size() > 0)
                        result.diffs.put(copy.getFileObject(), diffs);
                }
            }
            private ResultIterator findEmbeddedJava(final ResultIterator theMess) throws ParseException {
                final Collection<Embedding> todo = new LinkedList<Embedding>();
                //BFS should perform better than DFS in this dark.
                for (Embedding embedding : theMess.getEmbeddings()) {
                    if (JavacParser.MIME_TYPE.equals(embedding.getMimeType()))
                        return theMess.getResultIterator(embedding);
                    else
                        todo.add(embedding);
                }
                for (Embedding embedding : todo) {
                    ResultIterator result = findEmbeddedJava(theMess.getResultIterator(embedding));
                    if (result != null)
                        return result;
                }
                return null;
            }
        });
        return result;
    }
    
    public @NonNull Set<? extends FileObject> getModifiedFileObjects() {
        return diffs.keySet();
    }
    
    public List<? extends Difference> getDifferences(@NonNull FileObject fo) {
        return diffs.get(fo);
    }
    
    public @NonNull Set<File> getNewFiles() {
        Set<File> newFiles = new HashSet<File>();
        for (List<Difference> ds:diffs.values()) {
            for (Difference d: ds) {
                if (d.getKind() == Difference.Kind.CREATE) {
                    newFiles.add(org.openide.util.BaseUtilities.toFile(((CreateChange) d).getFileObject().toUri()));
                }
            }
        }
        return newFiles;
    }
    
    static LinkedList<Throwable> lastCommitted = new LinkedList<Throwable>();

    /**
     * Once all of the changes have been collected, this method can be used
     * to commit the changes to the source files
     */
    public void commit() throws IOException {
        if (this.committed) {
            throw new IllegalStateException ("Calling commit on already committed Modificationesult."); //NOI18N
        }
        try {
            IndexingController.getDefault().enterProtectedMode();
            try {
//                RepositoryUpdater.getDefault().lockRU();
                for (Map.Entry<FileObject, List<Difference>> me : diffs.entrySet()) {
                    commit(me.getKey(), me.getValue(), null);
                }
            } finally {
//                RepositoryUpdater.getDefault().unlockRU();
                Set<FileObject> alreadyRefreshed = new HashSet<>();
                try {
                    final SourceFileManager.ModifiedFiles modifiedFiles = SourceFileManager.getModifiedFiles();
                    for (FileObject srcFile : diffs.keySet()) {
                        Utilities.revalidate(srcFile);
                        alreadyRefreshed.add(srcFile);
                        modifiedFiles.fileModified(srcFile.toURI());
                    }
                } finally {
                    IndexingController.getDefault().exitProtectedMode(null);
                }
                ActiveDocumentProvider provider = Lookup.getDefault().lookup(ActiveDocumentProvider.class);
                if (provider != null) {
                    for (Document activeDocument : provider.getActiveDocuments()) {
                        FileObject fileObject = Utilities.getFileObject(activeDocument);
                        if (fileObject != null && !alreadyRefreshed.contains(fileObject)) {
                            Source source = Source.create(fileObject);
                            if (source != null) {
                                Utilities.revalidate(source);
                            }
                        }
                    }
                }
            }
            while (lastCommitted.size() > 10) {
                lastCommitted.removeLast();
            }
            lastCommitted.addFirst(creator);
        } finally {
            this.committed = true;
        }
    }
    
    static void commit (final FileObject fo, final List<Difference> differences, final Writer out) throws IOException {
        // if editor cookie was found and user does not provided his own
        // writer where he wants to see changes, commit the changes to 
        // found document.
        Source source = Source.create(fo);
        if (source != null && out == null) {
            final Document doc = source.getDocument(false);
            if (doc != null) {
                final IOException[] exceptions = new IOException [1];
                LineDocumentUtils.asRequired(doc, AtomicLockDocument.class).runAtomic(new Runnable () {
                    public void run () {
                        try {
                            commit2 (doc, differences, out);
                        } catch (IOException ex) {
                            exceptions [0] = ex;
                        }
                    }
                });
                if (exceptions [0] != null) {
                    LOG.log(Level.INFO, "ModificationResult commit failed with an exception: ", exceptions[0]);
                    int s = lastCommitted.size();
                    for (Throwable t : lastCommitted) {
                        LOG.log(Level.INFO, "Previous commit number " + s--, t);
                    }
                    throw exceptions [0];
                }
            return;
            }
        }
        Reader in = null;
        Writer out2 = out;
        JavaFileFilterImplementation filter = JavaFileFilterQuery.getFilter(fo);
        try {
            Charset encoding = FileEncodingQuery.getEncoding(fo);
            boolean[] hasReturnChar = new boolean[1];
            in = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(fo.asBytes()), encoding));
            if (filter != null) {
                in = filter.filterReader(in);
            }
            in = new FilteringReader(in, hasReturnChar);
            // initialize standard commit output stream, if user
            // does not provide his own writer
            boolean ownOutput = out != null;
            if (out2 == null) {
                out2 = new BufferedWriter(new OutputStreamWriter(fo.getOutputStream(), encoding));
                //going through filter only when writing to disk. When creating source for getResultingSource,
                //it is not passed through the write-filter (as that is what refactoring preview expects).
                if (filter != null) {
                    out2 = filter.filterWriter(out2);
                }
                out2 = new FilteringWriter(out2, hasReturnChar);
            }
            int offset = 0;                
            for (Difference diff : differences) {
                if (diff.isExcluded())
                    continue;
                if (Difference.Kind.CREATE == diff.getKind()) {
                    if (!ownOutput) {
                        createUnit(diff, null);
                    }
                    continue;
                }
                int pos = diff.getStartPosition().getOffset();
                int toread = pos - offset;
                char[] buff = new char[toread];
                int n;
                int rc = 0;
                while ((n = in.read(buff,0, toread - rc)) > 0 && rc < toread) {
                    out2.write(buff, 0, n);
                    rc+=n;
                    offset += n;
                }
                switch (diff.getKind()) {
                    case INSERT:
                        out2.write(diff.getNewText());
                        break;
                    case REMOVE:
                        int len = diff.getEndPosition().getOffset() - diff.getStartPosition().getOffset();
                        in.skip(len);
                        offset += len;
                        break;
                    case CHANGE:
                        len = diff.getEndPosition().getOffset() - diff.getStartPosition().getOffset();
                        in.skip(len);
                        offset += len;
                        out2.write(diff.getNewText());
                        break;
                }
            }                    
            char[] buff = new char[1024];
            int n;
            while ((n = in.read(buff)) > 0) {
                out2.write(buff, 0, n);
            }
        } finally {
            if (in != null)
                in.close();
            if (out2 != null)
                out2.close();
        }            
    }

    private static void commit2 (final Document doc, final List<Difference> differences, Writer out) throws IOException {
        for (Difference diff : differences) {
            if (diff.isExcluded())
                continue;
            switch (diff.getKind()) {
                case INSERT:
                case REMOVE:
                case CHANGE:
                    processDocument(doc, diff);
                    break;
                case CREATE:
                    createUnit(diff, out);
                    break;
            }
        }
    }
    
    private static void processDocument(final Document doc, final Difference diff) throws IOException {
        final BadLocationException[] blex = new BadLocationException[1];
        Runnable task = new Runnable() {

            public void run() {
                DocumentListener l = null;
                try {
                    // The listener was added while trying to fix #63323
                    // IMPORTANT: it has to be removed immediatelly after the change to stop marking the events
                    // as ignoring caret changes
                    l = new DocumentListener() {

                        @Override
                        public void insertUpdate(DocumentEvent e) {
                            DocumentUtilities.putEventPropertyIfSupported(e, "caretIgnore", Boolean.TRUE); // NOI18N
                        }

                        @Override
                        public void removeUpdate(DocumentEvent e) {
                            DocumentUtilities.putEventPropertyIfSupported(e, "caretIgnore", Boolean.TRUE); // NOI18N
                        }

                        @Override
                        public void changedUpdate(DocumentEvent e) {
                            DocumentUtilities.putEventPropertyIfSupported(e, "caretIgnore", Boolean.TRUE); // NOI18N
                        }
                    };
                    doc.addDocumentListener(l);
                    processDocumentLocked(doc, diff);
                } catch (BadLocationException ex) {
                    blex[0] = ex;
                } finally {
                    if (l != null) {
                        doc.removeDocumentListener(l);
                    }
                }
            }
        };
        AtomicLockDocument ald = LineDocumentUtils.asRequired(doc, AtomicLockDocument.class);
        assert ald != null : "Missing AtomicLockDocument stub";
        if (diff.isCommitToGuards()) {
            ald.runAtomic(task);
        } else {
            ald.runAtomicAsUser(task);
        }
        if (blex[0] != null) {
            IOException ioe = new IOException();
            ioe.initCause(blex[0]);
            throw ioe;
        }
    }
    
    private static void processDocumentLocked(Document doc, Difference diff) throws BadLocationException {
        switch (diff.getKind()) {
            case INSERT:
                doc.insertString(diff.getStartPosition().getOffset(), diff.getNewText(), null);
                break;
            case REMOVE:
                doc.remove(diff.getStartPosition().getOffset(), diff.getEndPosition().getOffset() - diff.getStartPosition().getOffset());
                break;
            case CHANGE: {
                // first insert the new content, THEN remove the old one. In situations where the content AFTER the
                // change is not writable this ordering allows to replace the content, but if we first delete, 
                // replacement cannot be inserted into the nonwritable area.
                int delta = diff.getNewText().length();
                int offs = diff.getStartPosition().getOffset();
                int removeLen = diff.getEndPosition().getOffset() - offs;
                
                doc.insertString(offs, diff.getNewText(), null);
                doc.remove(delta + offs, removeLen);
                break;
            }
        }
    }

    private static void createUnit(Difference diff, Writer out) {
        CreateChange change = (CreateChange) diff;
        Writer w = out;
        try {
            if (w == null) {
                change.getFileObject().openOutputStream();
                w = change.getFileObject().openWriter();
            }
            w.append(change.getNewText());
        } catch (IOException e) {
            Logger.getLogger(WorkingCopy.class.getName()).log(Level.SEVERE, e.getMessage(), e);
        } finally {
            if (w != null) {
                try {
                    w.close();
                } catch (IOException e) {
                    Logger.getLogger(WorkingCopy.class.getName()).log(Level.SEVERE, e.getMessage(), e);
                }
            }
        }
    }
    
    /**
     * Returned string represents preview of resulting source. No difference
     * really is applied. Respects {@code isExcluded()} flag of difference.
     * 
     * @param   there can be more resulting source, user has to specify
     *          which wants to preview.
     * @return  if changes are applied source looks like return string
     * @throws  IllegalArgumentException if the provided {@link FileObject} is not
     *                                   modified in this {@link ModificationResult}
     */
    public @NonNull String getResultingSource(@NonNull FileObject fileObject) throws IOException, IllegalArgumentException {
        Parameters.notNull("fileObject", fileObject);

        if (!getModifiedFileObjects().contains(fileObject)) {
            throw new IllegalArgumentException("File: " + FileUtil.getFileDisplayName(fileObject) + " is not modified in this ModificationResult");
        }
        
        StringWriter writer = new StringWriter();
        commit(fileObject, diffs.get(fileObject), writer);
        
        return writer.toString();
    }

    /**
     * Provides span of tree tagged with {@code tag}
     * @param tag
     * @return borders in target document
     * @since 0.37
     */
    public @NullUnknown int[] getSpan(@NonNull Object tag) {
        return tag2Span.get(tag);
    }
    
    public static class Difference {
              Kind kind;
        final Position startPos;
        final Position endPos;
              String oldText;
              String newText;
        final String description;
        private boolean excluded;
        private boolean ignoreGuards = false;
        
        /**
         * The Lookup acquired from the Source
         */
        private final Lookup ctxLookup;
        /**
         * The FileObject backing the original Source. If the Source is not backed
         * by a file, the Source instance will be stored in {@link #theSource} field.
         */
        private final FileObject sourceFile;
        
        /**
         * The Source object, only in the case the Source is not backed by a FileObject.
         */
        private final Source theSource;

        Difference(Kind kind, Position startPos, Position endPos, String oldText, String newText, String description, Source theSource) {
            this.kind = kind;
            this.startPos = startPos;
            this.endPos = endPos;
            this.oldText = oldText;
            this.newText = newText;
            this.description = description;
            this.excluded = false;
            if (theSource != null) {
                this.ctxLookup = theSource.getLookup();
                assert ctxLookup != null;
                this.sourceFile = theSource.getFileObject();
                if (sourceFile == null) {
                    this.theSource = theSource;
                } else {
                    this.theSource = null;
                }
            } else {
                this.ctxLookup = null;
                this.sourceFile = null;
                this.theSource = null;
            }
            // conservatively assume that pos could be null. They shouldn't be, but no doc states so.
            assert startPos == null || endPos == null || (startPos.getOffset() <= endPos.getOffset());
        }
        
        Difference(Kind kind, Position startPos, Position endPos, String oldText, String newText, Source theSource) {
            this(kind, startPos, endPos, oldText, newText, null, theSource);
        }
        
        public @NonNull Kind getKind() {
            return kind;
        }
        
        public @NonNull Position getStartPosition() {
            return startPos;
        }
        
        public @NonNull Position getEndPosition() {
            return endPos;
        }
        
        public @NonNull String getOldText() {
            return oldText;
        }
        
        public @NonNull String getNewText() {
            return newText;
        }
        
        public boolean isExcluded() {
            return excluded;
        }
        
        public void exclude(boolean b) {
            excluded = b;
        }

        /**
         * Gets flag if it is possible to write to guarded sections.
         * @return {@code true} in case the difference may be written even into
         *          guarded sections.
         * @see #guards(boolean)
         * @since 0.33
         */
        public boolean isCommitToGuards() {
            return ignoreGuards;
        }
        
        /**
         * Sets flag if it is possible to write to guarded sections.
         * @param b flag if it is possible to write to guarded sections
         * @since 0.33
         */
        public void setCommitToGuards(boolean b) {
            ignoreGuards = b;
        }

        @Override
        public String toString() {
            return kind + "<" + startPos.getOffset() + ", " + endPos.getOffset() + ">: " + oldText + " -> " + newText;
        }
        public String getDescription() {
            return description;
        }
        
        /**
         * Opens a Document where the Difference is to be applied. Throws IOException,
         * if the document open fails. If the document is already opened, returns the opened
         * instance. If the difference is CREATE and the document does not exist yet,
         * the method may return {@code null}
         * 
         * @throws IOException when document open fails
         * @return Document to apply the Difference.
         * @since java.source.base 1.1
         */
        @CheckForNull
        public Document openDocument() throws IOException {
            if (ctxLookup == null) {
                return null;
            } else if (theSource != null) {
                return theSource.getDocument(true);
            }
            
            // obtain the source again:
            Source s = Source.create(sourceFile, ctxLookup);
            if (s == null) {
                return null;
            }
            return s.getDocument(true);
        }
        
        public static enum Kind {
            INSERT,
            REMOVE,
            CHANGE,
            CREATE;
        }
    }
    
    static class CreateChange extends Difference {
        JavaFileObject fileObject;
        
        CreateChange(JavaFileObject fileObject, String text) {
            super(Kind.CREATE, null, null, null, text, 
                    NbBundle.getMessage(ModificationResult.class, "TXT_CreateFile", fileObject.getName()), null);
            this.fileObject = fileObject;
        }

        public JavaFileObject getFileObject() {
            return fileObject;
        }

        @Override
        public String toString() {
            return kind + "Create File: " + fileObject.getName() + "; contents = \"\n" + newText + "\"";
        }
    }
    
    private static final class FilteringReader extends Reader {
        
        private final Reader delegate;
        private final boolean[] hasReturnChar;
        private boolean beforeFirstLine = true;

        public FilteringReader(Reader delegate, boolean[] hasReturnChar) {
            this.delegate = delegate;
            this.hasReturnChar = hasReturnChar;
        }

        @Override
        public int read(char[] cbuf, int off, int len) throws IOException {
            int read;
            int j;
            
            do {
                read = delegate.read(cbuf, off, len);
                if (read == -1) {
                    return -1;
                }
                j = 0;

                for (int i = off; i < off + read; i++) {
                    if (cbuf[i] != '\r') {
                        cbuf[j++] = cbuf[i];
                        if (beforeFirstLine && cbuf[i] == '\n') {
                            beforeFirstLine = false;
                        }
                    } else if (beforeFirstLine) {
                        hasReturnChar[0] = true;
                        beforeFirstLine = false;
                    }
                }
            } while (j == 0 && read > 0);
            
            return j;
        }

        @Override
        public void close() throws IOException {
            delegate.close();
        }
    }
    
    private static final class FilteringWriter extends Writer {
        private final boolean[] hasReturnChar;
        private final Writer delegate;

        public FilteringWriter(Writer delegate, boolean[] hasReturnChar) {
            this.hasReturnChar = hasReturnChar;
            this.delegate = delegate;
        }

        @Override
        public void write(char[] cbuf, int off, int len) throws IOException {
            if (hasReturnChar[0]) {
                char[] buf = new char[len * 2];
                int j = 0;
                
                for (int i = off; i < off + len; i++) {
                    if (cbuf[i] == '\n') {
                        buf[j++] = '\r';
                        buf[j++] = '\n';
                    } else {
                        buf[j++] = cbuf[i];
                    }
                }
                
                delegate.write(buf, 0, j);
            } else {
                delegate.write(cbuf, off, len);
            }
        }

        @Override
        public void flush() throws IOException {
            delegate.flush();
        }

        @Override
        public void close() throws IOException {
            delegate.close();
        }
        
    }
}
