/*
 * ====================================================================
 * Copyright (c) 2004-2012 TMate Software Ltd.  All rights reserved.
 *
 * This software is licensed as described in the file COPYING, which
 * you should have received as part of this distribution.  The terms
 * are also available at http://svnkit.com/license.html
 * If newer versions of this license are posted there, you may use a
 * newer version instead, at your option.
 * ====================================================================
 */
package org.tmatesoft.svn.core.replicator;

import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Stack;

import org.tmatesoft.svn.core.ISVNDirEntryHandler;
import org.tmatesoft.svn.core.SVNCommitInfo;
import org.tmatesoft.svn.core.SVNErrorCode;
import org.tmatesoft.svn.core.SVNErrorMessage;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.SVNLogEntry;
import org.tmatesoft.svn.core.SVNLogEntryPath;
import org.tmatesoft.svn.core.SVNNodeKind;
import org.tmatesoft.svn.core.SVNProperties;
import org.tmatesoft.svn.core.SVNProperty;
import org.tmatesoft.svn.core.SVNPropertyValue;
import org.tmatesoft.svn.core.internal.util.SVNHashMap;
import org.tmatesoft.svn.core.internal.util.SVNHashSet;
import org.tmatesoft.svn.core.internal.util.SVNPathUtil;
import org.tmatesoft.svn.core.internal.wc.SVNErrorManager;
import org.tmatesoft.svn.core.internal.wc.SVNFileUtil;
import org.tmatesoft.svn.core.io.ISVNEditor;
import org.tmatesoft.svn.core.io.SVNRepository;
import org.tmatesoft.svn.core.io.SVNRepositoryFactory;
import org.tmatesoft.svn.core.io.diff.SVNDiffWindow;
import org.tmatesoft.svn.util.SVNLogType;

/**
 * The <b>SVNReplicationEditor</b> is an editor implementation used by a 
 * repository replicator as a bridge between an update editor for the source 
 * repository and a commit editor of the target one. This editor is provided 
 * to an update method of a source <b>SVNRepository</b> driver to properly translate 
 * the calls of that driver to calls to a commit editor of the destination <b>SVNRepository</b> 
 * driver.   
 * 
 * @version 1.3
 * @author  TMate Software Ltd.
 * @see     org.tmatesoft.svn.core.io.SVNRepository
 * @since   1.2
 */
public class SVNReplicationEditor implements ISVNEditor {

    private static final int ACCEPT = 0;
    private static final int IGNORE = 1;
    private static final int DECIDE = 2;

    private ISVNEditor myCommitEditor;
    private Map myCopiedPaths;
    private Map myChangedPaths;
    private Set myDeletedPaths;
    private SVNRepository myRepos;
    private Map myPathsToFileBatons;
    private Stack myDirsStack;
    private long myPreviousRevision;
    private long myTargetRevision;
    private SVNCommitInfo myCommitInfo;
    private SVNRepository mySourceRepository;
    
    /**
     * Creates a new replication editor.
     * 
     * <p>
     * <code>repository</code> must be created for the root location of 
     * the source repository which is to be replicated. 
     * 
     * @param repository    a source repository       
     * @param commitEditor  a commit editor received from the destination
     *                      repository driver (which also must be point to the 
     *                      root location of the destination repository)
     * @param revision      log information of the revision to be copied
     */
    public SVNReplicationEditor(SVNRepository repository, ISVNEditor commitEditor, SVNLogEntry revision) {
        myRepos = repository;
        myCommitEditor = commitEditor;
        myPathsToFileBatons = new SVNHashMap();
        myDirsStack = new Stack();
        myCopiedPaths = new SVNHashMap();
        myChangedPaths = revision.getChangedPaths();
        myDeletedPaths = new SVNHashSet();

        for(Iterator paths = myChangedPaths.keySet().iterator(); paths.hasNext();){
            String path = (String)paths.next();
            SVNLogEntryPath pathChange = (SVNLogEntryPath)myChangedPaths.get(path);
            //make sure it's a copy
            if((pathChange.getType() == SVNLogEntryPath.TYPE_REPLACED || pathChange.getType() == SVNLogEntryPath.TYPE_ADDED) && pathChange.getCopyPath() != null && pathChange.getCopyRevision() >= 0){
                myCopiedPaths.put(path, pathChange);
            }
        }
    }

    /**
     * Saves the target <code>revision</code>.
     * 
     * @param  revision         revision 
     * @throws SVNException 
     */
    public void targetRevision(long revision) throws SVNException {
        myPreviousRevision = revision - 1;
        myTargetRevision = revision;
    }

    /**
     * Starts a next replication transaction.
     * 
     * @param revision         target revision 
     * @throws SVNException 
     */
    public void openRoot(long revision) throws SVNException {
        //open root
        myCommitEditor.openRoot(myPreviousRevision);
        EntryBaton baton = new EntryBaton("/");
        baton.myPropsAct = ACCEPT;
        myDirsStack.push(baton);

    }

    /**
     * Removes <code>path</code> from the paths to be committed.
     * 
     * @param path 
     * @param revision 
     * @throws SVNException  exception with {@link SVNErrorCode#UNKNOWN} error code - if somehow 
     *                       chanded paths fetched from the log of the resource repository did not
     *                       reflect <code>path</code> deletion in <code>revision</code>  
     * 
     */
    public void deleteEntry(String path, long revision) throws SVNException {
        String absPath = getSourceRepository().getRepositoryPath(path);
        SVNLogEntryPath deletedPath = (SVNLogEntryPath) myChangedPaths.get(absPath);
        if (deletedPath != null && (deletedPath.getType() == SVNLogEntryPath.TYPE_DELETED || 
                deletedPath.getType() == SVNLogEntryPath.TYPE_REPLACED)) {
            if (deletedPath.getType() == SVNLogEntryPath.TYPE_DELETED) {
                myChangedPaths.remove(absPath);
            }
        } else {
            SVNErrorMessage err = SVNErrorMessage.create(SVNErrorCode.UNKNOWN, 
                    "Expected that path ''{0}'' is deleted in revision {1}", 
                    new Object[]{absPath, new Long(myPreviousRevision)});
            SVNErrorManager.error(err, SVNLogType.FSFS);
        }
        if (!myDeletedPaths.contains(path)) {
            myCommitEditor.deleteEntry(path, myPreviousRevision);
            myDeletedPaths.add(path);
        }
    }

    /**
     * Does nothing.
     * @param path 
     * @throws SVNException 
     */
    public void absentDir(String path) throws SVNException {
    }

    /**
     * Does nothing.
     * @param path 
     * @throws SVNException 
     */
    public void absentFile(String path) throws SVNException {
    }

    /**
     * Adds a new directory under the specified <code>path</code> to the target repository.
     * 
     * @param  path                  target directory path                
     * @param  copyFromPath          not used
     * @param  copyFromRevision      not used
     * @throws SVNException          exception with {@link SVNErrorCode#UNKNOWN} error code - if somehow 
     *                               chanded paths fetched from the log of the resource repository did not
     *                               reflect <code>path</code> addition  
     */
    public void addDir(String path, String copyFromPath, long copyFromRevision) throws SVNException {
        String absPath = getSourceRepository().getRepositoryPath(path);
        EntryBaton baton = new EntryBaton(absPath);
        myDirsStack.push(baton);
        SVNLogEntryPath changedPath = (SVNLogEntryPath) myChangedPaths.get(absPath);
        if (changedPath != null && (changedPath.getType() == SVNLogEntryPath.TYPE_ADDED || changedPath.getType() == SVNLogEntryPath.TYPE_REPLACED) && changedPath.getCopyPath() != null && changedPath.getCopyRevision() >= 0) {
            baton.myPropsAct = DECIDE;
            SVNProperties props = new SVNProperties();
            getSourceRepository().getDir(changedPath.getCopyPath(), changedPath.getCopyRevision(), props, (ISVNDirEntryHandler) null);
            baton.myProps = props;
            
            if (changedPath.getType() == SVNLogEntryPath.TYPE_REPLACED) {
                if (!myDeletedPaths.contains(path)) {
                    myCommitEditor.deleteEntry(path, myPreviousRevision);
                    myDeletedPaths.add(path);
                }
                myChangedPaths.remove(absPath);
            }
            myCommitEditor.addDir(path, changedPath.getCopyPath(), changedPath.getCopyRevision());
        } else if (changedPath != null && (changedPath.getType() == SVNLogEntryPath.TYPE_ADDED || changedPath.getType() == SVNLogEntryPath.TYPE_REPLACED)) {
            baton.myPropsAct = ACCEPT;
            myCommitEditor.addDir(path, null, -1);
        } else if (changedPath != null && changedPath.getType() == SVNLogEntryPath.TYPE_MODIFIED) {
            baton.myPropsAct = ACCEPT;
            myCommitEditor.openDir(path, myPreviousRevision);
        } else if (changedPath == null) {
            baton.myPropsAct = IGNORE;
            myCommitEditor.openDir(path, myPreviousRevision);
        } else {
            SVNErrorMessage err = SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "Unknown bug in addDir()");
            SVNErrorManager.error(err, SVNLogType.FSFS);
        }
    }

    /**
     * Opens a corresponding <code>path</code> in the target repository.
     * 
     * @param path            target directory path relative to the root of the edit 
     * @param revision        target directory revision
     * @throws SVNException 
     */
    public void openDir(String path, long revision) throws SVNException {
        EntryBaton baton = new EntryBaton(getSourceRepository().getRepositoryPath(path));
        baton.myPropsAct = ACCEPT;
        myDirsStack.push(baton);
        myCommitEditor.openDir(path, myPreviousRevision);
    }

    /**
     * Changes a property of the current directory.
     * 
     * @param name 
     * @param value 
     * @throws SVNException 
     */
    public void changeDirProperty(String name, SVNPropertyValue value) throws SVNException {
        if (!SVNProperty.isRegularProperty(name)) {
            return;
        }
        EntryBaton baton = (EntryBaton) myDirsStack.peek();
        if (baton.myPropsAct == ACCEPT) {
            myCommitEditor.changeDirProperty(name, value);
        } else if (baton.myPropsAct == DECIDE) {
            SVNPropertyValue propVal = baton.myProps.getSVNPropertyValue(name);
            if (propVal != null && propVal.equals(value)) {
                /*
                 * The properties seem to be the same as of the copy origin,
                 * do not reset them again.
                 */
                baton.myPropsAct = IGNORE;
                return;
            }
            /*
             * Properties do differ, accept them.
             */
            baton.myPropsAct = ACCEPT;
            myCommitEditor.changeDirProperty(name, value);
        }
    }

    /**
     * Closes the current opened dir.
     * 
     * @throws SVNException 
     */
    public void closeDir() throws SVNException {
        if (myDirsStack.size() > 1 && !myCopiedPaths.isEmpty()) {
            EntryBaton currentDir = (EntryBaton) myDirsStack.peek();
            completeDeletion(currentDir.myPath);
        }
        myDirsStack.pop();
        myCommitEditor.closeDir();
    }

    /**
     * Adds a new file.
     * 
     * @param path 
     * @param copyFromPath 
     * @param copyFromRevision 
     * @throws SVNException 
     */
    public void addFile(String path, String copyFromPath, long copyFromRevision) throws SVNException {
        String absPath = getSourceRepository().getRepositoryPath(path);
        EntryBaton baton = new EntryBaton(absPath);
        myPathsToFileBatons.put(path, baton);
        SVNLogEntryPath changedPath = (SVNLogEntryPath) myChangedPaths.get(absPath);
        
        if (changedPath != null && (changedPath.getType() == SVNLogEntryPath.TYPE_ADDED || changedPath.getType() == SVNLogEntryPath.TYPE_REPLACED) && changedPath.getCopyPath() != null && changedPath.getCopyRevision() >= 0) {
            baton.myPropsAct = DECIDE;
            baton.myTextAct = ACCEPT;
            SVNProperties props = new SVNProperties();
            if (areFileContentsEqual(absPath, myTargetRevision, changedPath.getCopyPath(), changedPath.getCopyRevision(), props)) {
                baton.myTextAct = IGNORE;
            }
            baton.myProps = props;
            if(changedPath.getType() == SVNLogEntryPath.TYPE_REPLACED){
                if (!myDeletedPaths.contains(path)) {
                    myCommitEditor.deleteEntry(path, myPreviousRevision);
                    myDeletedPaths.add(path);
                }
                myChangedPaths.remove(absPath);
            }
            myCommitEditor.addFile(path, changedPath.getCopyPath(), changedPath.getCopyRevision());
        } else if (changedPath != null && (changedPath.getType() == SVNLogEntryPath.TYPE_ADDED || changedPath.getType() == SVNLogEntryPath.TYPE_REPLACED)) {
            baton.myPropsAct = ACCEPT;
            baton.myTextAct = ACCEPT;
            if(changedPath.getType() == SVNLogEntryPath.TYPE_REPLACED){
                if (!myDeletedPaths.contains(path)) {
                    myCommitEditor.deleteEntry(path, myPreviousRevision);
                    myDeletedPaths.add(path);
                }
                myChangedPaths.remove(absPath);
            }
            myCommitEditor.addFile(path, null, -1);
        } else if (changedPath != null && changedPath.getType() == SVNLogEntryPath.TYPE_MODIFIED) {
            baton.myPropsAct = DECIDE;
            baton.myTextAct = ACCEPT;
            SVNLogEntryPath realPath = getFileCopyOrigin(absPath);
            if (realPath == null) {
                SVNErrorMessage err = SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "Unknown error, can't get the copy origin of a file");
                SVNErrorManager.error(err, SVNLogType.FSFS);
            }
            SVNProperties props = new SVNProperties();
            if (areFileContentsEqual(absPath, myTargetRevision, realPath.getCopyPath(), realPath.getCopyRevision(), props)) {
                baton.myTextAct = IGNORE;
            }
            baton.myProps = props;
            myCommitEditor.openFile(path, myPreviousRevision);
        } else if (changedPath == null) {
            baton.myPropsAct = IGNORE;
            baton.myTextAct = IGNORE;
        } else {
            SVNErrorMessage err = SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "Unknown bug in addFile()");
            SVNErrorManager.error(err, SVNLogType.FSFS);
        }
    }

    /**
     * Opens a file.
     * 
     * @param path 
     * @param revision 
     * @throws SVNException 
     */
    public void openFile(String path, long revision) throws SVNException {
        EntryBaton baton = new EntryBaton(getSourceRepository().getRepositoryPath(path));
        baton.myPropsAct = ACCEPT;
        baton.myTextAct = ACCEPT;
        myPathsToFileBatons.put(path, baton);
        myCommitEditor.openFile(path, myPreviousRevision);
    }

    /**
     * Starts applying text delta.
     * 
     * @param path 
     * @param baseChecksum 
     * @throws SVNException 
     */
    public void applyTextDelta(String path, String baseChecksum) throws SVNException {
        EntryBaton baton = (EntryBaton) myPathsToFileBatons.get(path);
        if (baton.myTextAct == ACCEPT) {
            myCommitEditor.applyTextDelta(path, baseChecksum);
        }
    }

    /**
     * Applies a next chunk of delta.
     * 
     * @param path 
     * @param diffWindow 
     * @return                dummy output stream 
     * @throws SVNException 
     */
    public OutputStream textDeltaChunk(String path, SVNDiffWindow diffWindow) throws SVNException {
        EntryBaton baton = (EntryBaton) myPathsToFileBatons.get(path);
        if (baton.myTextAct == ACCEPT) {
            return myCommitEditor.textDeltaChunk(path, diffWindow);
        }
        return SVNFileUtil.DUMMY_OUT;
    }

    /**
     * Handles text delta end.
     * 
     * @param path 
     * @throws SVNException 
     */
    public void textDeltaEnd(String path) throws SVNException {
        EntryBaton baton = (EntryBaton) myPathsToFileBatons.get(path);
        if (baton.myTextAct == ACCEPT) {
            myCommitEditor.textDeltaEnd(path);
        }
    }

    /**
     * Changes file property.
     * 
     * @param path 
     * @param name 
     * @param value 
     * @throws SVNException 
     */
    public void changeFileProperty(String path, String name, SVNPropertyValue value) throws SVNException {
        if (!SVNProperty.isRegularProperty(name)) {
            return;
        }
        EntryBaton baton = (EntryBaton) myPathsToFileBatons.get(path);
        if (baton.myPropsAct == ACCEPT) {
            myCommitEditor.changeFileProperty(path, name, value);
        } else if (baton.myPropsAct == DECIDE) {
            SVNPropertyValue propVal = baton.myProps.getSVNPropertyValue(name);
            if (propVal != null && propVal.equals(value)) {
                /*
                 * The properties seem to be the same as of the copy origin,
                 * do not reset them again.
                 */
                baton.myPropsAct = IGNORE;
                return;
            }
            /*
             * Properties do differ, accept them.
             */
            baton.myPropsAct = ACCEPT;
            myCommitEditor.changeFileProperty(path, name, value);
        }
    }

    /**
     * Closes the current opened file.
     * 
     * @param path 
     * @param textChecksum 
     * @throws SVNException 
     */
    public void closeFile(String path, String textChecksum) throws SVNException {
        EntryBaton baton = (EntryBaton) myPathsToFileBatons.get(path);
        if (baton.myTextAct != IGNORE || baton.myTextAct != IGNORE) {
            myCommitEditor.closeFile(path, textChecksum);
        }
    }

    /**
     * Commits the transaction.
     * @return commit info
     * @throws SVNException 
     */
    public SVNCommitInfo closeEdit() throws SVNException {
        myCommitInfo = myCommitEditor.closeEdit();
        if (mySourceRepository != null) {
            mySourceRepository.closeSession();
            mySourceRepository = null;
        }
        return myCommitInfo;
        
    }

    /**
     * Aborts the transaction. 
     * 
     * @throws SVNException 
     */
    public void abortEdit() throws SVNException {
        if (mySourceRepository != null) {
            mySourceRepository.closeSession();
            mySourceRepository = null;
        }
        myCommitEditor.abortEdit();
    }
    
    /**
     * Returns commit information on the revision 
     * committed to the replication destination repository.
     * 
     * @return commit info (revision, author, date)
     */
    public SVNCommitInfo getCommitInfo() {
        return myCommitInfo;
    }
    
    private SVNRepository getSourceRepository() throws SVNException {
        if (mySourceRepository == null) {
            mySourceRepository = SVNRepositoryFactory.create(myRepos.getLocation());
            mySourceRepository.setAuthenticationManager(myRepos.getAuthenticationManager());
            mySourceRepository.setDebugLog(myRepos.getDebugLog());
            mySourceRepository.setTunnelProvider(myRepos.getTunnelProvider());
            mySourceRepository.setCanceller(myRepos.getCanceller());
        }
        return mySourceRepository;

    }
    
    private void completeDeletion(String dirPath) throws SVNException {
        Collection pathsToDelete = new ArrayList();
        for(Iterator paths = myChangedPaths.keySet().iterator(); paths.hasNext();){
            String path = (String)paths.next();
            if (!path.startsWith(dirPath + "/")) {
                continue;
            }
            SVNLogEntryPath pathChange = (SVNLogEntryPath)myChangedPaths.get(path);
            if(pathChange.getType() == SVNLogEntryPath.TYPE_DELETED){
                String relativePath = path.substring(dirPath.length() + 1);
                pathsToDelete.add(relativePath);
            }
        }
        String[] pathsArray = (String[]) pathsToDelete.toArray(new String[pathsToDelete.size()]);
        Arrays.sort(pathsArray, SVNPathUtil.PATH_COMPARATOR);
        String currentOpened = "";
        for(int i = 0; i < pathsArray.length; i++) {
            String nextRelativePath = pathsArray[i];
            while(!"".equals(currentOpened) && nextRelativePath.indexOf(currentOpened) == -1){
                myCommitEditor.closeDir();
                currentOpened = SVNPathUtil.removeTail(currentOpened);
            }
            
            String nextRelativePathToDelete = null;
            if(!"".equals(currentOpened)){
                nextRelativePathToDelete = nextRelativePath.substring(currentOpened.length() + 1);
            }else{
                nextRelativePathToDelete = nextRelativePath;
            }

            String[] entries = nextRelativePathToDelete.split("/");
            int j = 0;
            for(j = 0; j < entries.length - 1; j++){
                currentOpened = SVNPathUtil.append(currentOpened, entries[j]);
                myCommitEditor.openDir(SVNPathUtil.append(dirPath, currentOpened), myPreviousRevision);
            }
            String pathToDelete = SVNPathUtil.append(currentOpened, entries[j]);
            String absPathToDelete = SVNPathUtil.append(dirPath, pathToDelete);
            if (!myDeletedPaths.contains(absPathToDelete)) {
                myCommitEditor.deleteEntry(absPathToDelete, myPreviousRevision);
                myDeletedPaths.add(absPathToDelete);
            }
            myChangedPaths.remove(absPathToDelete);
        }
        while(!"".equals(currentOpened)){
            myCommitEditor.closeDir();
            currentOpened = SVNPathUtil.removeTail(currentOpened);
        }
    }

    private SVNLogEntryPath getFileCopyOrigin(String path) throws SVNException {
        String[] paths = (String[]) myCopiedPaths.keySet().toArray(new String[myCopiedPaths.size()]);
        Arrays.sort(paths, 0, paths.length, SVNPathUtil.PATH_COMPARATOR);
        SVNLogEntryPath realPath = null;
        List candidates = new ArrayList();
        for (int i = 0; i < paths.length; i++) {
            String copiedPath = (String) paths[i];
            
            if (!path.startsWith(copiedPath + "/")) {
                continue;
            } else if (path.equals(copiedPath)) {
                return (SVNLogEntryPath) myCopiedPaths.get(copiedPath);
            }
            candidates.add(copiedPath);
        }
        // check candidates from the end of the list
        for(int i = candidates.size() - 1; i >=0; i--) {
            String candidateParent = (String) candidates.get(i);
            if (getSourceRepository().checkPath(candidateParent, myTargetRevision) != SVNNodeKind.DIR) {
                continue;
            }
            SVNLogEntryPath changedPath = (SVNLogEntryPath) myCopiedPaths.get(candidateParent);
            String fileRelativePath = path.substring(candidateParent.length() + 1);
            fileRelativePath = SVNPathUtil.append(changedPath.getCopyPath(), fileRelativePath);
            return new SVNLogEntryPath(path, ' ', fileRelativePath, changedPath.getCopyRevision());
        }
        return realPath;
    }

    private boolean areFileContentsEqual(String path1, long rev1, String path2, long rev2, SVNProperties props2) throws SVNException {
        SVNProperties props1 = new SVNProperties();
        props2 = props2 == null ? new SVNProperties() : props2;

        SVNRepository repos = getSourceRepository();
        repos.getFile(path1, rev1, props1, null);
        repos.getFile(path2, rev2, props2, null);
        String crc1 = props1.getStringValue(SVNProperty.CHECKSUM);
        String crc2 = props2.getStringValue(SVNProperty.CHECKSUM);
        return crc1 != null && crc1.equals(crc2);
    }

    private static class EntryBaton {
        
        public EntryBaton(String path) {
            myPath = path;
        }

        private String myPath;
        private int myPropsAct;
        private int myTextAct;
        private SVNProperties myProps;
    }
}

