/**************************************************************************
 OmegaT - Computer Assisted Translation (CAT) tool
          with fuzzy matching, translation memory, keyword search,
          glossaries, and translation leveraging into updated projects.

 Copyright (C) 2014 Thomas Cordonnier
               2015 Thomas Cordonnier
               2017 Thomas Cordonnier
               Home page: http://www.omegat.org/
               Support center: http://groups.yahoo.com/group/OmegaT/

 This file is part of OmegaT.

 OmegaT 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 3 of the License, or
 (at your option) any later version.

 OmegaT 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, see <http://www.gnu.org/licenses/>.
 **************************************************************************/

package org.omegat.core.matching.external;

import java.io.File;
import java.io.FileInputStream;
import java.lang.reflect.Constructor;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Collections;
import java.util.Properties;
import javax.swing.JOptionPane;

import org.omegat.core.Core;
import org.omegat.core.CoreEvents;
import org.omegat.core.events.IEntryEventListener;
import org.omegat.core.data.EntryKey;
import org.omegat.core.data.TMXEntry;
import org.omegat.core.data.PrepareTMXEntry;
import org.omegat.core.data.SourceTextEntry;
import org.omegat.core.data.ProjectTMX;
import org.omegat.core.data.IProject;
import org.omegat.filters2.master.PluginUtils;
import org.omegat.util.Language;
import org.omegat.util.Log;
import org.omegat.util.OStrings;
import org.omegat.util.StringUtil;

/**
 * Defines an writeable translation memory that can override project_save.tmx
 * There should be only zero or one such memory in the project : project_save.properties
 * 
 * @author Thomas CORDONNIER
 */
public class ProjectMemory extends ProjectTMX implements IEntryEventListener {

    /** In order to make link with editor, we must make the link EntryKey to first SourceTextEntry (i.e. the one with ) duplicates=first) easy **/ 
    public static class ExtendedCheckOrphanedCallback implements CheckOrphanedCallback {
        public final Map<String,SourceTextEntry> existSource = new HashMap<>();
        public final Map<EntryKey,SourceTextEntry> existKeys = new HashMap<>();

        public ExtendedCheckOrphanedCallback() {}        
        public ExtendedCheckOrphanedCallback(List<SourceTextEntry> entries) {
            for (SourceTextEntry ste: entries) addEntry(ste);
        }
        
        public boolean existEntryInProject(EntryKey key) { return existKeys.containsKey(key); }
        public boolean existSourceInProject(String src) { return existSource.containsKey(src); }        
        public void addEntry(SourceTextEntry ste) {
            // Associate the key and source to _first_ entry
            if (! existSource.containsKey(ste.getKey().sourceText)) existSource.put(ste.getKey().sourceText, ste);
            if (! existKeys.containsKey(ste.getKey())) existKeys.put(ste.getKey(), ste);
        }
    }

    /**
     * Methods of this interface will be called by ProjectMemory but are implemented by tiers tools
     **/
    public interface IExternalProjectMemory {
        /**
         * Fetches last modifications from the database
         * @param timeStamp		
         *		a <i>server-side</i> timestamp : 
         *			the client should take care about an eventual time-unsynchronization
         *			because the server does not know all its clients
         **/
        IEntryCursor findChanges (long timeStamp) throws Exception;

        /**
         * Register current translation to a server. Parameters are as in ProjectTMX.setTranslation
         */
        void registerTranslation(SourceTextEntry ste, TMXEntry te, boolean isDefault) throws Exception;
        
        /** 
         * Creates a translation memory provider returning only orphan segments from server. <br/>
         * This enables to see orphan segments in matches pane and eventually in searches, without keeping them in project_save.tmx <br/>
         * In case it is not possible, preferably return null. <br/>
         * Note : the provider <i>must</i> implement a way to remove non-orphan segments: else, they will be merged with the contents of project memory during search.
         **/
        default IExternalMemory asTranslationMemory() throws Exception {
            return null;
        }        
    }
    

    private IExternalProjectMemory memory;
    private IProject project;
    private long timeStamp = 0;
    // force to have less than 1 query per given period (default, 1 second)
    // this does not mean that there will be 1 query per period, but less: 
    // if the user activates several entries during a second, only the first one will generate a refresh
    private long timeStamp_interval = 1000;
    // indicates whenever we keep distant orphans locally. Possile values detailed when used in the code, search for [DistantOrphans]
    private Object distantOrphanPolicy = null;
    
    public static final long TIMESTAMP_REFRESH = -1L;
    
    private ExtendedCheckOrphanedCallback sourceEntryFinder;
    
    public ProjectMemory(
        Language sourceLanguage, Language targetLanguage, boolean isSentenceSegmentingEnabled, File saveTmxFile, ExtendedCheckOrphanedCallback callback,
        IProject project, File propertiesFile
    ) throws Exception {
        super(sourceLanguage, targetLanguage, isSentenceSegmentingEnabled, saveTmxFile, callback);
        this.project = project;
        this.sourceEntryFinder = callback;
        Properties config = new Properties();
        FileInputStream fis = new FileInputStream (propertiesFile); config.load (fis); fis.close();
        Class<?> memoryClass = PluginUtils.getPluginsClassLoader().loadClass(config.getProperty("class"));
        try { this.memory = (IExternalProjectMemory) memoryClass.getConstructor (ProjectMemory.class, Properties.class).newInstance (this, config); }
        catch (Exception nsm) {
            this.memory = (IExternalProjectMemory) memoryClass.getConstructor (Properties.class).newInstance (config);
        }
        try {
            timeStamp_interval = Long.parseLong(config.getProperty("timestamp.interval")); 
            Log.log ("Created project memory with timestamp interval " + timeStamp_interval + " milliseconds");
        } catch (Exception e) { 
            /* not declared? not an error */
            Log.log ("Created project memory with default timestamp interval (1 second)");
        }
        if (config.getProperty("project.reuse-save_tmx") != null) 
            try { timeStamp = new File (project.getProjectProperties().getProjectInternal() + "/project_save.tmx").lastModified(); } catch (Exception estamp) {}
        String distantOrphansPolicySpec = config.getProperty("distant.keep-orphans");
        if (distantOrphansPolicySpec == null) // not specified, so default behavior, which is:
            registerAsExternalMemory(targetLanguage, false); // use IExternalMemory if possible, else NONE.
        else // values specified by user in project_save.properties
            if (distantOrphansPolicySpec.startsWith("asMemory")) registerAsExternalMemory(targetLanguage, true); // same as before except that we must alert the user if conversion failed
            else if (distantOrphansPolicySpec.startsWith("RAM"))
                if (distantOrphansPolicySpec.contains("%")) // keep but only upto a certain memory size
                    try {
                        distantOrphansPolicySpec = distantOrphansPolicySpec.substring(3).trim();
                        distantOrphansPolicySpec = distantOrphansPolicySpec.replace('%',' ').trim();
                        distantOrphanPolicy = Double.parseDouble(distantOrphansPolicySpec);
                    } catch (Exception eDouble) {
                        distantOrphanPolicy = 1.0; // keep as much orphans as project size
                    }
                else distantOrphanPolicy = Double.MAX_VALUE; // keep all
            else if (distantOrphansPolicySpec.toLowerCase().startsWith("browse ")) // replace iterators
                try {
                    distantOrphanPolicy = Long.parseLong(config.getProperty(distantOrphansPolicySpec.substring(6).trim()));
                } catch (Exception eStamp) {
                    distantOrphanPolicy = Long.MIN_VALUE;
                }
            /* else distantOrphanPolicy = null; // other values are ignored. To specify that you don't want distant orphans at all, use value NONE */
        CoreEvents.registerEntryEventListener(this);
    }
    
    private void registerAsExternalMemory(Language targetLanguage, boolean required) {
        try { // use IExternalMemory if possible, else NONE.
            IExternalMemory asTra = memory.asTranslationMemory(); distantOrphanPolicy = asTra;
            if (asTra == null) {
                Log.log ("Cannot convert to translation memory: orphan segments will be ignored");
                if (required) JOptionPane.showMessageDialog(null, "Cannot convert to translation memory: orphan segments will be ignored", "Error", JOptionPane.ERROR_MESSAGE);
            } 
            else if (project == Core.getProject()) 	// main project memory
                 project.getTransMemories().put(OStrings.getString("CT_DISTANT_ORPHAN_STRINGS"), asTra);
            else // memory for another language (tmx2source)
                project.getTransMemories().put(OStrings.getString("CT_DISTANT_ORPHAN_STRINGS") + " (" + targetLanguage + ")", asTra);
        } catch (Exception e) { 
            Log.log ("Failed to create orphan segments provider : " + e.getMessage());
            if (required) JOptionPane.showMessageDialog(null, "Failed to create orphan segments provider : " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
        }
    }
    
    public void onNewFile(String activeFileName) {}
    
    public void onEntryActivated(SourceTextEntry newEntry) {
        try {
            Set<Integer> changes = refresh();
            if (changes.size() > 0) {
                Core.getEditor().commitAndDeactivate();
                Core.getEditor().refreshEntries (changes);
                Core.getEditor().activateEntry(); 
            }
        } catch (Exception e) {
            e.printStackTrace();
            JOptionPane.showMessageDialog(null, "Could not synchronize with server:\n" + e.getClass().getName() + " : " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
        }
    }
    
    private synchronized Set<Integer> refresh() throws Exception {
        if (timeStamp == TIMESTAMP_REFRESH) return Collections.emptySet(); // do not call the server during refresh
        else if (System.currentTimeMillis() - timeStamp < timeStamp_interval) return Collections.emptySet(); // do not call the server when user is too fast
        
        try {
            IEntryCursor cursor = memory.findChanges (this.timeStamp);
            this.timeStamp = TIMESTAMP_REFRESH; // flag to say that we are doing refresh
            final Set<Integer> changedSegments = new HashSet<Integer>();
            boolean keepOrphans = false;
            if (distantOrphanPolicy instanceof Double) {
                double count = (defaults.size() + alternatives.size()) / Core.getProject().getAllEntries().size();
                if (count < (double) distantOrphanPolicy) keepOrphans = true;
            }

            while (cursor.next()) {
                EntryKey key = cursor.buildEntryKey(); SourceTextEntry ste;
                if (key == null) ste = sourceEntryFinder.existSource.get(cursor.getEntrySource()); // source text entry
                else ste = sourceEntryFinder.existKeys.get(key);
                if (ste == null) 
                    if (! keepOrphans) continue; // do not set orphan entries
                    else // we must create a source text entry, only for the time of setting. Not very efficient, but it is how OmegaT works.
                        if (key != null) ste = new SourceTextEntry(key, -1, null, Collections.EMPTY_LIST, true);
                        else ste = new SourceTextEntry(new EntryKey("", cursor.getEntrySource(), "","","",""), -1, null, Collections.EMPTY_LIST, true);
                
                project.setTranslation (ste, cursor.toPrepareTMXEntry(), key == null, null);
                changedSegments.add(ste.entryNum());
                if (key == null) for (SourceTextEntry dup: ste.getDuplicates()) changedSegments.add(dup.entryNum());
            }
        
            return changedSegments;
        } finally { // in all cases, even if something fails...
            this.timeStamp = System.currentTimeMillis();
        }
    }

    /**
     * Set new translation.
     * 
     * Save the translation in ProjectTmx hashmaps, then in the database.
     */
    @Override
    public void setTranslation(SourceTextEntry ste, TMXEntry te, boolean isDefault) {
        super.setTranslation(ste, te, isDefault);
        if (this.timeStamp == TIMESTAMP_REFRESH) return; // do not update database if we are refreshing...
        
        try {            
            memory.registerTranslation (ste, te, isDefault);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    public final ExtendedCheckOrphanedCallback getEntriesFinder() { return sourceEntryFinder; }

    @SuppressWarnings("unchecked") @Override
    public void iterateByDefaultTranslations(IProject.DefaultTranslationsIterator it) {
        super.iterateByDefaultTranslations(it);
        // browse distant orphans only if specified
        long useDistantOrphans; try { useDistantOrphans = (Long) distantOrphanPolicy; } catch (Exception ex) { return; }
        if (useDistantOrphans == Long.MAX_VALUE) return;
        try {
           IEntryCursor changes = memory.findChanges(useDistantOrphans);
           while (changes.next()) {
               if (this.defaults.containsKey(changes.getEntrySource())) continue;
               EntryKey key = changes.buildEntryKey(); if (key != null) continue;
               it.iterate(changes.getEntrySource(), new TMXEntry(changes, true));
           }
        } catch (Exception e) {
            Log.log(e);
        }
    }

    @SuppressWarnings("unchecked") @Override
    public void iterateByMultipleTranslations(IProject.MultipleTranslationsIterator it) {
        super.iterateByMultipleTranslations(it);
        // browse distant orphans only if specified
        long useDistantOrphans; try { useDistantOrphans = (Long) distantOrphanPolicy; } catch (Exception ex) { return; }
        if (useDistantOrphans == Long.MAX_VALUE) return;
        try {
            IEntryCursor changes = memory.findChanges(useDistantOrphans);
            while (changes.next()) {
               EntryKey key = changes.buildEntryKey(); if (key == null) continue;
               if (this.alternatives.containsKey(key)) continue;
               it.iterate(key, new TMXEntry(changes, false));
            }
        } catch (Exception e) {
            Log.log(e);
        }
    }
    
}
