/*
 * Copyright PMEase (c) 2005-2008,
 * Date: 2008-10-11
 * All rights reserved.
 */
package com.pmease.quickbuild.plugin.scm.svn;

import java.io.File;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

import org.apache.commons.lang.Validate;
import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;
import org.hibernate.validator.constraints.NotEmpty;

import com.pmease.quickbuild.Context;
import com.pmease.quickbuild.Quickbuild;
import com.pmease.quickbuild.QuickbuildException;
import com.pmease.quickbuild.ScriptEngine;
import com.pmease.quickbuild.annotation.Editable;
import com.pmease.quickbuild.annotation.Expressions;
import com.pmease.quickbuild.annotation.Multiline;
import com.pmease.quickbuild.annotation.Password;
import com.pmease.quickbuild.annotation.ScriptApi;
import com.pmease.quickbuild.annotation.Scriptable;
import com.pmease.quickbuild.execution.Commandline;
import com.pmease.quickbuild.execution.LineConsumer;
import com.pmease.quickbuild.repositorysupport.LocalChange;
import com.pmease.quickbuild.repositorysupport.ProofBuildSupport;
import com.pmease.quickbuild.util.Constants;
import com.pmease.quickbuild.util.ExceptionUtils;
import com.pmease.quickbuild.util.FileUtils;
import com.pmease.quickbuild.util.StringUtils;

/**
 * @author robin
 *
 */
@SuppressWarnings("serial")
@ScriptApi
public class SvnProofBuildSupport extends ProofBuildSupport<LocalChange> {
	
	private SvnRepository repository;
	
	private String workingCopies;

	private String userName; 
	
	private String password;
	
	private String updateCondition = "true";
	
	private String commitCondition = "build.successful";
	
	private String commitComment;
	
	@Editable(order=100, description=
		"Specify working copy paths at user's desktop. Local change will be collected from " +
		"these working copies and sent to QuickBuild to run a proof build.<br>" +
		"<strong>NOTE:</strong> mutiple working copy paths need to be separated by comma or " +
		"new line character."
		)
	@NotEmpty
	@ScriptApi("Get path of working copies at user's desktop to collect local change from.")
	@Scriptable
	public String getWorkingCopies() {
		return workingCopies;
	}

	public void setWorkingCopies(String workingCopies) {
		this.workingCopies = workingCopies;
	}

	@Editable(order=150, description=
		"Optionally specify the Subversion user to be used at user's desktop."
		)
	@ScriptApi("Get user name to be used at user's desktop.")
	@Scriptable
	public String getUserName() {
		return userName;
	}

	public void setUserName(String userName) {
		this.userName = userName;
	}

	@Editable(order=160, description="Specify the Subversion password to be used at user's desktop.")
	@Password
	@ScriptApi("Get password to be used at user's desktop.")
	@Scriptable
	public String getPassword() {
		return password;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	@Editable(order=200, description=
		"Specify the condition to update working copies. If this condition is satisfied, " +
		"the work copies specified above will be updated before local change is collected. " +
		"Updating working copies will make the proof build more accurate and you should " +
		"normally set this condition to <b>true</b>."
		)
	@Expressions({
		"always update", "true",
		"do not update", "false"
		})
	@NotEmpty
	@ScriptApi("Get the condition to update working copies before collect local change.")
	@Scriptable
	public String getUpdateCondition() {
		return updateCondition;
	}

	public void setUpdateCondition(String updateCondition) {
		this.updateCondition = updateCondition;
	}

	@Editable(order=300, description=
		"Specify the condition to commit local change. If this condition is satisfied, " +
		"local change in the workspace specified above will be commit after the build " +
		"finishes."
		)
	@Expressions({
		"commit only when build is successful", "build.successful", 
		"always commit", "true",
		"do not commit", "false"
		})
	@NotEmpty
	@ScriptApi("Get the condition to commit local change after build finishes.")
	@Scriptable
	public String getCommitCondition() {
		return commitCondition;
	}
	
	public void setCommitCondition(String commitCondition) {
		this.commitCondition = commitCondition;
	}

	@Editable(order=400, description=
		"Specify the comment when commit the local change. This property only takes " +
		"effect if the commit condition specified above is satisfied."
		)
	@Multiline
	@ScriptApi("Get the comment to commit local change after build finishes. " +
			"Null if not specified.")
	@Scriptable
	public String getCommitComment() {
		return commitComment;
	}
	
	public void setCommitComment(String commitComment) {
		this.commitComment = commitComment;
	}
	
	public SvnProofBuildSupport(SvnRepository repository) {
		this.repository = repository;
	}

	private List<File> getWorkingCopyDirs() {
		List<File> workingCopies = new ArrayList<File>();
		for (String workingCopyPath: StringUtils.split(getWorkingCopies(), "\n\r,"))
			workingCopies.add(new File(workingCopyPath.trim()));
		return workingCopies;
	}

	public void buildFinished() {
		Boolean isCommit = (Boolean) Quickbuild.getInstance(ScriptEngine.class)
				.evaluate(getCommitCondition(),  
						Context.buildEvalContext(this, null));
		if (isCommit) {
			Context.getLogger().info("Committing local change...");
	    	for (File workingCopy: getWorkingCopyDirs()) {
	    		Commandline cmdline = repository.buildSvnCmd();
	    		
	    		cmdline.addArgLine("status . --no-ignore --non-interactive");
	    		if (repository.isSupportTrustServerCert())
	    			cmdline.addArgValue("--trust-server-cert");
	    		
	        	if (repository.isIgnoreExternals())
	        		cmdline.addArgValue("--ignore-externals");
	    		
	    		final List<String> externals = new ArrayList<String>();
	    		externals.add("");

	    		cmdline.execute(workingCopy, new LineConsumer() {

	    			@Override
	    			public void consume(String line) {
	                	Context.getLogger().debug(line);
	                	if (line.startsWith("Performing status on external item at")) {
	                		externals.add(StringUtils.replace(StringUtils.substringBeforeLast(
	                				StringUtils.substringAfter(line, "'"), "'"), "\\", "/"));
	                	}            	
	    			}
	    			
	    		}, new LineConsumer.WarnLogger()).checkReturnCode();
	    		
	    		for (String external: externals) {
	    			if (external.length() == 0) {
			    		Context.getLogger().info("Checkin local change in working copy: " 
			    				+ workingCopy.getAbsolutePath());
	    			} else {
			    		Context.getLogger().info("Checkin local change in external '{}' of working copy '{}'...", 
			    				external, workingCopy.getAbsolutePath());
	    			}
		    		cmdline = repository.buildSvnCmd().addArgValue("commit");
		    		repository.addLogLevelSwitches(cmdline);
		    		repository.addAuthenticationSwitches(cmdline, 
		    				getUserName()!=null?getUserName():repository.getUserName(), 
		    				getPassword()!=null?getPassword():repository.getPassword());
		    		cmdline.addArgValue("--non-interactive");
		    		if (repository.isSupportTrustServerCert())
		    			cmdline.addArgValue("--trust-server-cert");
		    		
		    		cmdline.addArgValue("-m");
		    		if (getCommitComment() != null)
		    			cmdline.addArgValue(getCommitComment());
		    		else 
		    			cmdline.addArgValue("Committed by QuickBuild as result of proof build.");
		    		
		    		cmdline.execute(new File(workingCopy, external), new LineConsumer.DebugLogger(), new LineConsumer.WarnLogger())
		    				.checkReturnCode();
	    		}
	    	}
		}
	}

	public File getCheckoutFile(String repositoryPath) {
		String topRepositoryPath = repository.getNormalizedUrls().getSecond()
				.substring(repository.getNormalizedUrls().getFirst().length());
		if (FileUtils.getRelativePath(repositoryPath, topRepositoryPath) != null) {
			String relativeRepositoryPath = repositoryPath.substring(topRepositoryPath.length());
			return new File(repository.getDestDir(), relativeRepositoryPath);
		} else
			return null;
	}

	/*
	 * status command output format changed slightly between subversion 1.5 and 1.6, refer to 
	 * subversion 1.6 release notes. 
	 */
	public LocalChange getLocalChange(File changeStoreDir) {
		Boolean isUpdate = (Boolean) Quickbuild.getInstance(ScriptEngine.class)
				.evaluate(getUpdateCondition(), 
						Context.buildEvalContext(this, null));

		if (isUpdate) {
			Context.getLogger().info("Updating working copies...");
			for (File workingCopy: getWorkingCopyDirs()) {
				Context.getLogger().info("Updating working copy: " + workingCopy.getAbsolutePath());
				Commandline cmdline = repository.buildSvnCmd()
						.addArgValue("update").addArgValue("--non-interactive");
	    		if (repository.isSupportTrustServerCert())
	    			cmdline.addArgValue("--trust-server-cert");
				
	    		repository.addAuthenticationSwitches(cmdline, getUserName(), getPassword());
				repository.addLogLevelSwitches(cmdline);
				
				cmdline.execute(workingCopy, new LineConsumer.DebugLogger(), new LineConsumer.WarnLogger())
						.checkReturnCode();
			}		
		}

		Context.getLogger().info("Checking status of working copies...");

		LocalChange change = new LocalChange();
		for (File workingCopy: getWorkingCopyDirs()) {
			String repositoryPathForWorkingCopy = getRepositoryPathForWorkingCopy(workingCopy);
			calcLocalChange(change, workingCopy, repositoryPathForWorkingCopy, null, changeStoreDir);
		}
		
		return change;
	}
	
	private void calcLocalChange(LocalChange change, File workingCopy, String workingCopyRepoPath, 
			String external, File changeStoreDir) {
		Commandline cmdline = repository.buildSvnCmd();
		
		// add --no-ignore option to print out ignored items as 'I'
		cmdline.addArgLine("status");
		if (external == null)
			cmdline.addArgValue(".");
		else
			cmdline.addArgValue(external);
		cmdline.addArgValue("--no-ignore").addArgValue("--non-interactive");
		if (repository.isSupportTrustServerCert())
			cmdline.addArgValue("--trust-server-cert");
		
    	if (repository.isIgnoreExternals() || external != null)
    		cmdline.addArgValue("--ignore-externals");
		
		final List<String> outputs = new ArrayList<String>();
		final List<String> externals = new ArrayList<String>();

		cmdline.execute(workingCopy, new LineConsumer() {

			@Override
			public void consume(String line) {
            	Context.getLogger().debug(line);
            	if (line.length() >= 7) {
            		char action = line.charAt(0);
            		if (action == 'C' || action == '!' || action == '~' || 
            				action == '?' || action == 'D' || action == 'M' || 
            				action == 'A' || action == 'I' || action == 'R') {
                    	String entryPath = line.substring(7).trim();
                    	
                    	// modifications of subversion externals will be displayed as absolute path
                    	if (!new File(entryPath).isAbsolute())
                    		outputs.add(StringUtils.replace(line, "\\", "/"));
            		}
            	} 
            	
            	if (line.startsWith("Performing status on external item at")) {
            		externals.add(StringUtils.replace(StringUtils.substringBeforeLast(
            				StringUtils.substringAfter(line, "'"), "'"), "\\", "/"));
            	}            	
			}
			
		}, new LineConsumer.WarnLogger()).checkReturnCode();
		
		/*
		 * history paths can be generated by moving a directory/file.
		 */
		Set<String> excludedHistoryChildPaths = new HashSet<String>();
		List<String> historyPaths = new ArrayList<String>();
		for (Iterator<String> it = outputs.iterator(); it.hasNext();) {
			String line = it.next();
        	String entryPath = line.substring(7).trim();
        	char action = line.charAt(0);
        	boolean isHistory = (line.charAt(3) == '+');
        	if (line.charAt(6) == 'C') {
        		throw new QuickbuildException("Failed to collect local " +
        				"change as tree conflict is found for path '" + 
        				new File(workingCopy, entryPath).getAbsolutePath() + "'.");
        	} else if (action == 'C') {
        		throw new QuickbuildException("Failed to collect local " +
        				"change as conflict is found for path '" + 
        				new File(workingCopy, entryPath).getAbsolutePath() + "'.");
        	} else if (action == '!') {
        		throw new QuickbuildException("Failed to collect local " +
        				"change as item is missing for path '" + 
        				new File(workingCopy, entryPath).getAbsolutePath() + "'.");
        	} else if (action == '~') {
        		throw new QuickbuildException("Failed to collect local " +
        				"change as obstructed item is found for path '" + 
        				new File(workingCopy, entryPath).getAbsolutePath() + "'.");
        	} else if (action == '?' || action == 'D' || action == 'I' || action == 'M' 
        			|| action == 'R' || action == 'A' && !isHistory) {
        		excludedHistoryChildPaths.add(entryPath);
        	} else if (action == 'A' && isHistory) {
        		historyPaths.add(entryPath);
        		it.remove();
        	}
		}

		/*
		 * Handle below case for example:
		 * A  +  dir1
		 * A  +  dir1/dir2 
		 */
		FileUtils.sortPaths(historyPaths);
		List<String> normalizedHistoryPaths = new ArrayList<String>();
		String previousHistoryPath = null;
		for (String historyPath: historyPaths) {
			if (previousHistoryPath == null 
					|| FileUtils.getRelativePath(historyPath, previousHistoryPath) == null) {
				normalizedHistoryPaths.add(historyPath);
				previousHistoryPath = historyPath;
			}
		}
		
		/*
		 * Add children of history paths automatically except for those marked with 'I', 
		 * '?', 'D', 'M', 'R', 'A'. Note that entries marked with 'M', 'R', 'A' are excluded
		 * here as they will be added separately later 
		 */
		for (String historyPath: normalizedHistoryPaths)
			addHistoryChildPaths(workingCopy, historyPath, outputs, excludedHistoryChildPaths);
		
		for (String line: outputs) {
        	String entryPath = line.substring(7).trim();
        	String entryRepositoryPath = workingCopyRepoPath + "/" + entryPath;            	
        	File entryFile = new File(workingCopy, entryPath);
			File entryFileInStore = new File(changeStoreDir, entryRepositoryPath);
        	char action = line.charAt(0);
        	switch (action) {
        	case 'M':
        	case 'R':
        		if (entryFileInStore != null) {
        			Validate.isTrue(entryFile.isFile());
        			FileUtils.copyFile(entryFile, entryFileInStore);
        			if (isCoveredByHistory(normalizedHistoryPaths, entryPath)) 
        				change.getAddPaths().add(entryRepositoryPath);
        			else {
            			change.getModifyPaths().add(entryRepositoryPath);
            			if (external != null)
            				FileUtils.createFile(new File(entryFileInStore.getAbsolutePath() + ".qbprev"));
        			}
        		}
        		break;
        	case 'D':
        		if (entryFileInStore != null && !isCoveredByHistory(normalizedHistoryPaths, entryPath))
        			change.getDeletePaths().add(entryRepositoryPath);
        		break;
        	case 'A':
    			if (entryFileInStore != null) {
    				if (entryFile.isFile())
    					FileUtils.copyFile(entryFile, entryFileInStore);
    				else
    					FileUtils.createDir(entryFileInStore);
    				change.getAddPaths().add(entryRepositoryPath);
    			}
        		break;
        	}
		}

		for (String each: externals)
			calcLocalChange(change, workingCopy, workingCopyRepoPath, each, changeStoreDir);
	}

	private void addHistoryChildPaths(File workingCopy, String historyPath, List<String> addTo, 
			Set<String> exludedHistoryChildPaths) {
		if (!exludedHistoryChildPaths.contains(historyPath)) {
			addTo.add("A       " + historyPath);
			File historyFile = new File(workingCopy, historyPath);
			if (historyFile.isDirectory()) {
				for (File childFile: historyFile.listFiles()) {
					if (!childFile.getName().equals(".svn")) {
						addHistoryChildPaths(workingCopy, historyPath + "/" + childFile.getName(), 
								addTo, exludedHistoryChildPaths);
					}
				}
			}
		}
	}
	
	private String getRepositoryPathForWorkingCopy(File workingCopy) {
        Commandline cmdline = repository.buildSvnCmd()
        		.addArgValue("info").addArgValue(workingCopy.getAbsolutePath());

		repository.addAuthenticationSwitches(cmdline, 
				getUserName()!=null?getUserName():repository.getUserName(), 
				getPassword()!=null?getPassword():repository.getPassword());
        cmdline.addArgValue("--non-interactive");
		if (repository.isSupportTrustServerCert())
			cmdline.addArgValue("--trust-server-cert");
		
        cmdline.addArgValue("--xml");
        final StringBuffer buffer = new StringBuffer();

        cmdline.execute(new LineConsumer(Constants.UTF8) {

			@Override
			public void consume(String line) {
            	Context.getLogger().debug(line);
                buffer.append(line);
                buffer.append("\n");
			}
        	
        }, new LineConsumer.WarnLogger()).checkReturnCode();

        SAXReader reader = new SAXReader();
        try {
            Document doc = reader.read(new StringReader(buffer.toString()));
            Element entryElement = doc.getRootElement().element("entry");
            if (entryElement == null)
                throw new QuickbuildException("Can not find entry element for information of " +
                		"working copy '" + workingCopy.getAbsolutePath() + "'");
            String workingCopyRootUrl = repository.decodeUrl(entryElement.element("repository").elementText("root"));
            String workingCopyUrl = repository.decodeUrl(entryElement.elementText("url"));
            return workingCopyUrl.substring(workingCopyRootUrl.length());
        } catch (Exception e) {
            throw ExceptionUtils.wrapAsUnchecked(e);
		}        
		
	}

	private boolean isCoveredByHistory(List<String> historyPaths, String path) {
		for (String historyPath: historyPaths) {
			if (FileUtils.getRelativePath(path, historyPath) != null)
				return true;
		}
		return false;
	}
	
	/**
	 * Get reppository object.
	 * @return
	 */
	@ScriptApi
	public SvnRepository getRepository() {
		return repository;
	}
}
