/*  Copyright (C) 2003-2011 JabRef contributors.
    This program is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation; either version 2 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License along
    with this program; if not, write to the Free Software Foundation, Inc.,
    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package net.sf.jabref.collab;

import java.io.File;
import java.io.IOException;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Vector;
import java.util.ArrayList;

import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
import javax.swing.tree.DefaultMutableTreeNode;

import net.sf.jabref.*;
import net.sf.jabref.export.FileActions;
import net.sf.jabref.export.SaveException;
import net.sf.jabref.export.SaveSession;
import net.sf.jabref.groups.GroupTreeNode;
import net.sf.jabref.imports.OpenDatabaseAction;
import net.sf.jabref.imports.ParserResult;


public class ChangeScanner extends Thread {

    final double MATCH_THRESHOLD = 0.4;
    final String[] sortBy = new String[] {"year", "author", "title" };
    File f;
    BibtexDatabase inMem, inTemp = null;
    MetaData mdInMem, mdInTemp;
    BasePanel panel;
    JabRefFrame frame;

    /**
     * We create an ArrayList to hold the changes we find. These will be added in the form
     * of UndoEdit objects. We instantiate these so that the changes found in the file on disk
     * can be reproduced in memory by calling redo() on them. REDO, not UNDO!
     */
    //ArrayList changes = new ArrayList();
    DefaultMutableTreeNode changes = new DefaultMutableTreeNode(Globals.lang("External changes"));

    //  NamedCompound edit = new NamedCompound("Merged external changes")

    public ChangeScanner(JabRefFrame frame, BasePanel bp) { //, BibtexDatabase inMem, MetaData mdInMem) {
        panel = bp;
        this.frame = frame;
        this.inMem = bp.database();
        this.mdInMem = bp.metaData();
        // Set low priority:
        setPriority(Thread.MIN_PRIORITY);
    }

    public void changeScan(File f) {
        this.f = f;
        start();
    }

    public void run() {
        try {
            //long startTime = System.currentTimeMillis();

            // Parse the temporary file.
            File tempFile = Globals.fileUpdateMonitor.getTempFile(panel.fileMonitorHandle());
            ParserResult pr = OpenDatabaseAction.loadDatabase(tempFile,
            Globals.prefs.get("defaultEncoding"));
            inTemp = pr.getDatabase();
            mdInTemp = pr.getMetaData();
            // Parse the modified file.
            pr = OpenDatabaseAction.loadDatabase(f, Globals.prefs.get("defaultEncoding"));
            BibtexDatabase onDisk = pr.getDatabase();
            MetaData mdOnDisk = pr.getMetaData();

            // Sort both databases according to a common sort key.
            EntryComparator comp = new EntryComparator(false, true, sortBy[2]);
            comp = new EntryComparator(false, true, sortBy[1], comp);
            comp = new EntryComparator(false, true, sortBy[0], comp);
            EntrySorter sInTemp = inTemp.getSorter(comp);
            comp = new EntryComparator(false, true, sortBy[2]);
            comp = new EntryComparator(false, true, sortBy[1], comp);
            comp = new EntryComparator(false, true, sortBy[0], comp);
            EntrySorter sOnDisk = onDisk.getSorter(comp);
            comp = new EntryComparator(false, true, sortBy[2]);
            comp = new EntryComparator(false, true, sortBy[1], comp);
            comp = new EntryComparator(false, true, sortBy[0], comp);
            EntrySorter sInMem = inMem.getSorter(comp);

            // Start looking at changes.
            scanMetaData(mdInMem, mdInTemp, mdOnDisk);
            scanPreamble(inMem, inTemp, onDisk);
            scanStrings(inMem, inTemp, onDisk);


            scanEntries(sInMem, sInTemp, sOnDisk);
            
            scanGroups(mdInMem, mdInTemp, mdOnDisk);


        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }


    public boolean changesFound() {
        return changes.getChildCount() > 0;
    }

    public void displayResult(final DisplayResultCallback fup) {
        if (changes.getChildCount() > 0) {
            SwingUtilities.invokeLater(new Runnable() {
                public void run() {
                    ChangeDisplayDialog dial = new ChangeDisplayDialog(frame, panel, inTemp, changes);
                    Util.placeDialog(dial, frame);
                    dial.setVisible(true); // dial.show(); -> deprecated since 1.5
                    fup.scanResultsResolved(dial.isOkPressed());
                    if (dial.isOkPressed()) {
                        // Overwrite the temp database:
                        storeTempDatabase();
                    }
                }
            });

        } else {
            JOptionPane.showMessageDialog(frame, Globals.lang("No actual changes found."),
            Globals.lang("External changes"), JOptionPane.INFORMATION_MESSAGE);
            fup.scanResultsResolved(true);
        }
    }

    private void storeTempDatabase() {
        new Thread(new Runnable() {
            public void run() {
                try {
                    SaveSession ss = FileActions.saveDatabase(inTemp, mdInTemp,
                        Globals.fileUpdateMonitor.getTempFile(panel.fileMonitorHandle()), Globals.prefs,
                        false, false, panel.getEncoding(), true);
                    ss.commit();
                } catch (SaveException ex) {
                    System.out.println("Problem updating tmp file after accepting external changes");
                }


            }
        }).start();
    }

    private void scanMetaData(MetaData inMem, MetaData inTemp, MetaData onDisk) {
        MetaDataChange mdc = new MetaDataChange(inMem, inTemp);
        ArrayList<String> handledOnDisk = new ArrayList<String>();
        // Loop through the metadata entries of the "tmp" database, looking for
        // matches
        for (Iterator i = inTemp.iterator(); i.hasNext();) {
            String key = (String)i.next();
            // See if the key is missing in the disk database:
            Vector<String> vod = onDisk.getData(key);
            if (vod == null) {
                mdc.insertMetaDataRemoval(key);
            }
            else {
                // Both exist. Check if they are different:
                Vector<String> vit = inTemp.getData(key);
                if (!vod.equals(vit))
                    mdc.insertMetaDataChange(key, vod);
                // Remember that we've handled this one:
                handledOnDisk.add(key);
            }
        }

        // See if there are unhandled keys in the disk database:
        for (Iterator i = onDisk.iterator(); i.hasNext();) {
            String key = (String)i.next();
            if (!handledOnDisk.contains(key)) {
                mdc.insertMetaDataAddition(key, onDisk.getData(key));
            }
        }

        if (mdc.getChangeCount() > 0)
            changes.add(mdc);
    }


    private void scanEntries(EntrySorter mem, EntrySorter tmp, EntrySorter disk) {

        // Create pointers that are incremented as the entries of each base are used in
        // successive order from the beginning. Entries "further down" in the "disk" base
        // can also be matched.
        int piv1 = 0, piv2 = 0;

        // Create a HashSet where we can put references to entry numbers in the "disk"
        // database that we have matched. This is to avoid matching them twice.
        HashSet<String> used = new HashSet<String>(disk.getEntryCount());
        HashSet<Integer> notMatched = new HashSet<Integer>(tmp.getEntryCount());

        // Loop through the entries of the "tmp" database, looking for exact matches in the "disk" one.
        // We must finish scanning for exact matches before looking for near matches, to avoid an exact
        // match being "stolen" from another entry.
        mainLoop: for (piv1=0; piv1<tmp.getEntryCount(); piv1++) {

            // First check if the similarly placed entry in the other base matches exactly.
            double comp = -1;
            // (if there are not any entries left in the "disk" database, comp will stay at -1,
            // and this entry will be marked as nonmatched).
            if (!used.contains(""+piv2) && (piv2<disk.getEntryCount())) {
                comp = DuplicateCheck.compareEntriesStrictly(tmp.getEntryAt(piv1), disk.getEntryAt(piv2));
            }
            if (comp > 1) {
                used.add(""+piv2);
                piv2++;
                continue mainLoop;
            }

            // No? Then check if another entry matches exactly.
            if (piv2 < disk.getEntryCount()-1) {
                for (int i = piv2+1; i < disk.getEntryCount(); i++) {
                    if (!used.contains(""+i))
                        comp = DuplicateCheck.compareEntriesStrictly(tmp.getEntryAt(piv1), disk.getEntryAt(i));
                    else
                        comp = -1;

                    if (comp > 1) {
                        used.add("" + i);
                        continue mainLoop;
                    }
                }
            }

            // No? Add this entry to the list of nonmatched entries.
            notMatched.add(new Integer(piv1));
        }


        // Now we've found all exact matches, look through the remaining entries, looking
        // for close matches.
        if (notMatched.size() > 0) {

            for (Iterator<Integer> it=notMatched.iterator(); it.hasNext();) {

                Integer integ = it.next();
                piv1 = integ.intValue();


                // These two variables will keep track of which entry most closely matches the
                // one we're looking at, in case none matches completely.
                int bestMatchI = -1;
                double bestMatch = 0;
                double comp = -1;

                if (piv2 < disk.getEntryCount()-1) {
                    for (int i = piv2; i < disk.getEntryCount(); i++) {
                        if (!used.contains(""+i)) {
                            comp = DuplicateCheck.compareEntriesStrictly(tmp.getEntryAt(piv1),
                            disk.getEntryAt(i));
                        }
                        else
                            comp = -1;

                        if (comp > bestMatch) {
                            bestMatch = comp;
                            bestMatchI = i;
                        }
                    }
                }

                if (bestMatch > MATCH_THRESHOLD) {
                    used.add(""+bestMatchI);
                    it.remove();

                    EntryChange ec = new EntryChange(bestFit(tmp, mem, piv1), tmp.getEntryAt(piv1),
                    disk.getEntryAt(bestMatchI));
                    changes.add(ec);

                    // Create an undo edit to represent this change:
                    //NamedCompound ce = new NamedCompound("Modified entry");
                    //ce.addEdit(new UndoableRemoveEntry(inMem, disk.getEntryAt(bestMatchI), panel));
                    //ce.addEdit(new UndoableInsertEntry(inMem, tmp.getEntryAt(piv1), panel));
                    //ce.end();
                    //changes.add(ce);

                    //System.out.println("Possible match for entry:");
                    //System.out.println("----------------------------------------------");

                }
                else {
                    EntryDeleteChange ec = new EntryDeleteChange(bestFit(tmp, mem, piv1), tmp.getEntryAt(piv1));
                    changes.add(ec);
          /*NamedCompound ce = new NamedCompound("Removed entry");
          ce.addEdit(new UndoableInsertEntry(inMem, tmp.getEntryAt(piv1), panel));
          ce.end();
          changes.add(ce);*/

                }

            }

        }

        // Finally, look if there are still untouched entries in the disk database. These
        // mayhave been added.
        if (used.size() < disk.getEntryCount()) {
            for (int i=0; i<disk.getEntryCount(); i++) {
                if (!used.contains(""+i)) {

                    // See if there is an identical dupe in the mem database:
                    boolean hasAlready = false;
                    for (int j = 0; j < mem.getEntryCount(); j++) {
                        if (DuplicateCheck.compareEntriesStrictly(mem.getEntryAt(j),
                            disk.getEntryAt(i)) >= 1) {
                            hasAlready = true;
                            break;
                        }
                    }
                    if (!hasAlready) {
                        EntryAddChange ec = new EntryAddChange(disk.getEntryAt(i));
                        changes.add(ec);
                    }
          /*NamedCompound ce = new NamedCompound("Added entry");
          ce.addEdit(new UndoableRemoveEntry(inMem, disk.getEntryAt(i), panel));
          ce.end();
          changes.add(ce);*/
                }
            }
            //System.out.println("Suspected new entries in file: "+(disk.getEntryCount()-used.size()));
        }
    }

    /**
     * Finds the entry in neu best fitting the specified entry in old. If no entries get a score
     * above zero, an entry is still returned.
     * @param old EntrySorter
     * @param neu EntrySorter
     * @param index int
     * @return BibtexEntry
     */
    private BibtexEntry bestFit(EntrySorter old, EntrySorter neu, int index) {
        double comp = -1;
        int found = 0;
        loop: for (int i=0; i<neu.getEntryCount(); i++) {
            double res = DuplicateCheck.compareEntriesStrictly(old.getEntryAt(index),
            neu.getEntryAt(i));
            if (res > comp) {
                comp = res;
                found = i;
            }
            if (comp > 1)
                break loop;
        }
        return neu.getEntryAt(found);
    }

    private void scanPreamble(BibtexDatabase inMem, BibtexDatabase onTmp, BibtexDatabase onDisk) {
        String mem = inMem.getPreamble(),
        tmp = onTmp.getPreamble(),
        disk = onDisk.getPreamble();
        if (tmp != null) {
            if ((disk == null) || !tmp.equals(disk))
                changes.add(new PreambleChange(tmp, mem, disk));
        }
        else if ((disk != null) && !disk.equals("")) {
            changes.add(new PreambleChange(tmp, mem, disk));
        }
    }

    private void scanStrings(BibtexDatabase inMem, BibtexDatabase onTmp, BibtexDatabase onDisk) {
        int nTmp = onTmp.getStringCount(),
        nDisk = onDisk.getStringCount();
        if ((nTmp == 0) && (nDisk == 0))
            return;

        HashSet<Object> used = new HashSet<Object>();
        HashSet<Object> usedInMem = new HashSet<Object>();
        HashSet<String> notMatched = new HashSet<String>(onTmp.getStringCount());

        // First try to match by string names.
        //int piv2 = -1;
        mainLoop: for (String key : onTmp.getStringKeySet()){
            BibtexString tmp = onTmp.getString(key);

            //      for (int j=piv2+1; j<nDisk; j++)
            for (String diskId : onDisk.getStringKeySet()){
                if (!used.contains(diskId)) {
                    BibtexString disk = onDisk.getString(diskId);
                    if (disk.getName().equals(tmp.getName())) {
                        // We have found a string with a matching name.
                        if ((tmp.getContent() != null) && !tmp.getContent().equals(disk.getContent())) {
                            // But they have nonmatching contents, so we've found a change.
                            BibtexString mem = findString(inMem, tmp.getName(), usedInMem);
                            if (mem != null)
                                changes.add(new StringChange(mem, tmp, tmp.getName(),
                                mem.getContent(),
                                tmp.getContent(), disk.getContent()));
                            else
                                changes.add(new StringChange(null, tmp, tmp.getName(), null, tmp.getContent(), disk.getContent()));
                        }
                        used.add(diskId);
                        //if (j==piv2)
                        //  piv2++;
                        continue mainLoop;
                    }

                }
            }
            // If we get here, there was no match for this string.
            notMatched.add(tmp.getId());
        }

        // See if we can detect a name change for those entries that we couldn't match.
        if (notMatched.size() > 0) {
            for (Iterator<String> i = notMatched.iterator(); i.hasNext();){
                BibtexString tmp = onTmp.getString(i.next());

                // If we get to this point, we found no string with matching name. See if we
                // can find one with matching content.
                for (String diskId : onDisk.getStringKeySet()){

                	if (!used.contains(diskId)) {
                        BibtexString disk = onDisk.getString(diskId);

                        if (disk.getContent().equals(tmp.getContent())) {
                            // We have found a string with the same content. It cannot have the same
                            // name, or we would have found it above.

                            // Try to find the matching one in memory:
                            BibtexString bsMem = null;
                            
                            for (String memId : inMem.getStringKeySet()){
                                BibtexString bsMem_cand = inMem.getString(memId);
                                if (bsMem_cand.getContent().equals(disk.getContent()) &&
                                !usedInMem.contains(memId)) {
                                    usedInMem.add(memId);
                                    bsMem = bsMem_cand;
                                    break;
                                }
                            }

                            changes.add(new StringNameChange(bsMem, tmp, bsMem.getName(),
                            tmp.getName(), disk.getName(),
                            tmp.getContent()));
                            i.remove();
                            used.add(diskId);

                        }
                    }
                }
            }
        }

        if (notMatched.size() > 0) {
            // Still one or more non-matched strings. So they must have been removed.
            for (Iterator<String> i = notMatched.iterator(); i.hasNext(); ) {
                String nmId = i.next();
                BibtexString tmp = onTmp.getString(nmId);
                BibtexString mem = findString(inMem, tmp.getName(), usedInMem);
                if (mem != null) { // The removed string is not removed from the mem version.
                    changes.add(new StringRemoveChange(tmp, tmp, mem));
                }
            }
        }


        // Finally, see if there are remaining strings in the disk database. They
        // must have been added.
        for (Iterator<String> i=onDisk.getStringKeySet().iterator(); i.hasNext();) {
            String diskId = i.next();
            if (!used.contains(diskId)) {
                BibtexString disk = onDisk.getString(diskId);
                //System.out.println(disk.getName());
                used.add(diskId);
                changes.add(new StringAddChange(disk));
            }
        }
    }

    private BibtexString findString(BibtexDatabase base, String name, HashSet<Object> used) {
        if (!base.hasStringLabel(name))
            return null;
        for (Iterator<String> i=base.getStringKeySet().iterator(); i.hasNext();) {
            String key = i.next();
            BibtexString bs = base.getString(key);
            if (bs.getName().equals(name) && !used.contains(key)) {
                used.add(key);
                return bs;
            }
        }
        return null;
    }

    /**
     * This method only detects wheter a change took place or not. It does not
     * determine the type of change. This would be possible, but difficult to do
     * properly, so I rather only report the change.
     */
    public void scanGroups(MetaData inMem, MetaData onTmp, MetaData onDisk) {
        final GroupTreeNode groupsTmp = onTmp.getGroups();
        final GroupTreeNode groupsDisk = onDisk.getGroups();
        if (groupsTmp == null && groupsDisk == null)
            return;
        if ((groupsTmp != null && groupsDisk == null)
                || (groupsTmp == null && groupsDisk != null)) {
            changes.add(new GroupChange(groupsDisk, groupsTmp));
            return;
        }
        if (groupsTmp.equals(groupsDisk))
            return;
        changes.add(new GroupChange(groupsDisk, groupsTmp));
        return;

//
//        if (((vOnTmp == null) || (vOnTmp.size()==0)) && ((vOnDisk == null) || (vOnDisk.size()==0))) {
//            // No groups defined in either the tmp or disk version.
//            return;
//        }
//
//        // To avoid checking for null all the time, make empty vectors to replace null refs. We clone
//        // non-null vectors so we can remove the elements as we finish with them.
//        if (vOnDisk == null)
//            vOnDisk = new Vector(0);
//        else
//            vOnDisk = (Vector)vOnDisk.clone();
//        if (vOnTmp == null)
//            vOnTmp = new Vector(0);
//        else
//            vOnTmp = (Vector)vOnTmp.clone();
//        if (vInMem == null)
//            vInMem = new Vector(0);
//        else
//            vInMem = (Vector)vInMem.clone();
//
//        // If the tmp version has groups, iterate through these and compare with disk version:
//        while (vOnTmp.size() >= 1) {
//            AbstractGroup group = (AbstractGroup)vOnTmp.firstElement();
//            vOnTmp.removeElementAt(0);
//            int pos = GroupSelector.findGroupByName(vOnDisk,group.getName());
//            if (pos == -1) {
//                // Couldn't find the group.
//                changes.add(new GroupAddOrRemove(group, false));
//            } else {
//                AbstractGroup diskGroup = (AbstractGroup)vOnDisk.elementAt(pos);
//
//                if (!diskGroup.equals(group)) {
//                    // Group has changed.
//                    changes.add(new GroupChange(inMem, group, diskGroup));
//                }
//
//                // Remove this group, since it's been accounted for.
//                vOnDisk.remove(pos);
//            }
//        }
//
//        // If there are entries left in the disk version, these must have been added.
//        while (vOnDisk.size() >= 1) {
//            AbstractGroup group = (AbstractGroup)vOnDisk.firstElement();
//            vOnDisk.removeElementAt(0);
//            changes.add(new GroupAddOrRemove(group, true));
//        }
    }


    public static interface DisplayResultCallback {
        public void scanResultsResolved(boolean resolved);
    }
}
