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

 Copyright (C) 2012 Alex Buloichik
               2014 Aaron Madlon-Kay
               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.team;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Collection;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.swing.JOptionPane;

import org.eclipse.jgit.api.CheckoutCommand;
import org.eclipse.jgit.api.CloneCommand;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.LogCommand;
import org.eclipse.jgit.api.LsRemoteCommand;
import org.eclipse.jgit.api.ResetCommand;
import org.eclipse.jgit.api.Status;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.InvalidRemoteException;
import org.eclipse.jgit.api.errors.JGitInternalException;
import org.eclipse.jgit.api.errors.TransportException;
import org.eclipse.jgit.errors.UnsupportedCredentialItem;
import org.eclipse.jgit.lib.ProgressMonitor;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.StoredConfig;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.CredentialItem;
import org.eclipse.jgit.transport.CredentialsProvider;
import org.eclipse.jgit.transport.PushResult;
import org.eclipse.jgit.transport.RemoteRefUpdate;
import org.eclipse.jgit.transport.URIish;
import org.omegat.core.Core;
import org.omegat.gui.dialogs.TeamUserPassDialog;
import org.omegat.util.FileUtil;
import org.omegat.util.Log;
import org.omegat.util.OStrings;
import org.omegat.util.StringUtil;

/**
 * GIT repository connection implementation.
 * 
 * Project should use "autocrlf=true" options. Otherwise, repository can be changed every 5 minutes. This
 * property will be setted by OmegaT on checkoutFullProject().
 * 
 * GIT project can't be locked, because git requires to update full snapshot.
 * 
 * @author Alex Buloichik (alex73mail@gmail.com)
 * @author Martin Fleurke
 * @author Aaron Madlon-Kay
 */
public class GITRemoteRepository implements IRemoteRepository {
    private static final Logger LOGGER = Logger.getLogger(GITRemoteRepository.class.getName());

    protected static String LOCAL_BRANCH = "master";
    protected static String REMOTE_BRANCH = "origin/master";
    protected static String REMOTE = "origin";
    boolean readOnly;

    File localDirectory;
    protected Repository repository;

    private MyCredentialsProvider myCredentialsProvider;

    public static boolean isGITDirectory(File localDirectory) {
        return getLocalRepositoryRoot(localDirectory) != null;
    }

    public boolean isFilesLockingAllowed() {
        return true;
    }

    public GITRemoteRepository(File localDirectory) throws Exception {

        try {
            //workaround for: file c:\project\omegat\project_save.tmx is not contained in C:\project\.
            //The git repo uses the canonical path for some actions, and if c: != C: then an error is raised.
            //if we make it canonical already here, then we don't have that problem.
            localDirectory  = localDirectory.getCanonicalFile();
        } catch (Exception e) {}

        this.localDirectory = localDirectory;
        CredentialsProvider prevProvider = CredentialsProvider.getDefault();
        myCredentialsProvider = new MyCredentialsProvider(this);
        if (prevProvider instanceof MyCredentialsProvider) {
            myCredentialsProvider.setCredentials(((MyCredentialsProvider)prevProvider).credentials);
        }
        CredentialsProvider.setDefault(myCredentialsProvider);
        File localRepositoryDirectory = getLocalRepositoryRoot(localDirectory);
        if (localRepositoryDirectory != null) {
            repository = Git.open(localRepositoryDirectory).getRepository();
        }
    }

    public void checkoutFullProject(String repositoryURL) throws Exception {
        Log.logInfoRB("GIT_START", "clone");
        CloneCommand c = Git.cloneRepository();
        c.setURI(repositoryURL);
        c.setDirectory(localDirectory);
        try {
            c.call();
        } catch (InvalidRemoteException e) {
            FileUtil.deleteTree(localDirectory);
            Throwable cause = e.getCause();
            if (cause != null && cause instanceof org.eclipse.jgit.errors.NoRemoteRepositoryException) {
                BadRepositoryException bre = new BadRepositoryException(((org.eclipse.jgit.errors.NoRemoteRepositoryException)cause).getLocalizedMessage());
                bre.initCause(e);
                throw bre;
            }
            throw e;
        }
        repository = Git.open(localDirectory).getRepository();
        new Git(repository).submoduleInit().call();
        new Git(repository).submoduleUpdate().call();

        //Deal with line endings. A normalized repo has LF line endings. 
        //OmegaT uses line endings of OS for storing tmx files.
        //To do auto converting, we need to change a setting:
        StoredConfig config = repository.getConfig();
        if ("\r\n".equals(FileUtil.LINE_SEPARATOR)) {
            //on windows machines, convert text files to CRLF
            config.setBoolean("core", null, "autocrlf", true);
        } else {
            //on Linux/Mac machines (using LF), don't convert text files
            //but use input format, unchanged.
            //NB: I don't know correct setting for OS'es like MacOS <= 9, 
            // which uses CR. Git manual only speaks about converting from/to
            //CRLF, so for CR, you probably don't want conversion either.
            config.setString("core", null, "autocrlf", "input");
        }
        config.save();
        myCredentialsProvider.saveCredentials();
        Log.logInfoRB("GIT_FINISH", "clone");
    }

    public boolean isChanged(File file) throws Exception {
        Log.logInfoRB("GIT_START", "status");
        String relativeFile = FileUtil.computeRelativePath(repository.getWorkTree(), file);
        Status status = new Git(repository).status().call();
        Log.logInfoRB("GIT_FINISH", "status");
        boolean result = status.getModified().contains(relativeFile);
        Log.logDebug(LOGGER, "GIT modified status of {0} is {1}", relativeFile, result);
        return result;
    }

    public boolean isUnderVersionControl(File file) throws Exception {
        boolean result = file.exists();
        String relativeFile = FileUtil.computeRelativePath(repository.getWorkTree(), file);
        Status status = new Git(repository).status().call();

        if (status.getAdded().contains(relativeFile) || status.getModified().contains(relativeFile)
                || status.getChanged().contains(relativeFile)
                || status.getConflicting().contains(relativeFile)
                || status.getMissing().contains(relativeFile) || status.getRemoved().contains(relativeFile)) {
            result = true;
        }
        if (status.getUntracked().contains(relativeFile)) {
            result = false;
        }
        Log.logDebug(LOGGER, "GIT file {0} is under version control: {1}", relativeFile, result);
        return result;
    }
    
    public void setCredentials(Credentials credentials) {
        if (credentials == null) {
            return;
        }
        myCredentialsProvider.setCredentials(credentials);
        setReadOnly(credentials.readOnly);
    }

    public void setReadOnly(boolean value) {
        readOnly = value;
    }

    public String getBaseRevisionId(File file) throws Exception {
        RevWalk walk = new RevWalk(repository);

        Ref localBranch = repository.getRef("HEAD");
        Ref remoteBranch = repository.getRef(REMOTE_BRANCH);
        RevCommit headCommit = walk.lookupCommit(localBranch.getObjectId());
        RevCommit upstreamCommit = walk.lookupCommit(remoteBranch.getObjectId());
        Log.logDebug(LOGGER, "GIT HEAD rev: {0}", headCommit.getName());
        Log.logDebug(LOGGER, "GIT origin/master rev: {0}", upstreamCommit.getName());

        LogCommand cmd = new Git(repository).log().addRange(upstreamCommit, headCommit);
        Iterable<RevCommit> commitsToUse = cmd.call();
        RevCommit last = null;
        for (RevCommit commit : commitsToUse) {
            last = commit;
        }
        RevCommit commonBase = last != null ? last.getParent(0) : upstreamCommit;
        Log.logDebug(LOGGER, "GIT commonBase rev: {0}", commonBase.getName());
        return commonBase.getName();
    }

    public void restoreBase(File[] files) throws Exception {
        String baseRevisionId = getBaseRevisionId(files[0]);
        Log.logDebug(LOGGER, "GIT restore base {0} for {1}", baseRevisionId, (Object) files);
        //undo local changes of specific file.
        CheckoutCommand checkoutCommand = new Git(repository).checkout();
        for (File f: files) {
            String relativeFileName = FileUtil.computeRelativePath(repository.getWorkTree(), f);
            checkoutCommand.addPath(relativeFileName);
        }
        checkoutCommand.call();
        //reset repo to previous version. Can cause conflicts for other files!
        new Git(repository).checkout().setName(baseRevisionId).call();
    }

    public void reset() throws Exception {
        Log.logInfoRB("GIT_START", "reset");
        try {
            new Git(repository).reset().setMode(ResetCommand.ResetType.HARD).call();
            Log.logInfoRB("GIT_FINISH", "reset");
        } catch (Exception ex) {
            Log.logErrorRB("GIT_ERROR", "reset", ex.getMessage());
            checkAndThrowException(ex);
        }
    }

    public void updateFullProject() throws NetworkException, Exception {
        Log.logInfoRB("GIT_START", "pull");
        try {
            new Git(repository).fetch().call();
            new Git(repository).checkout().setName(REMOTE_BRANCH).call();
            new Git(repository).branchDelete().setBranchNames(LOCAL_BRANCH).setForce(true).call();
            new Git(repository).checkout().setStartPoint(REMOTE_BRANCH).setCreateBranch(true)
                    .setName(LOCAL_BRANCH).setForce(true).call();
            new Git(repository).submoduleUpdate().call();
            Log.logInfoRB("GIT_FINISH", "pull");
        } catch (Exception ex) {
            Log.logErrorRB("GIT_ERROR", "pull", ex.getMessage());
            checkAndThrowException(ex);
        }
    }

    public void download(File[] files) throws NetworkException, Exception {
        Log.logInfoRB("GIT_START", "download");
        try {
            new Git(repository).fetch().call();
            new Git(repository).checkout().setName(REMOTE_BRANCH).call();
            new Git(repository).branchDelete().setBranchNames(LOCAL_BRANCH).setForce(true).call();
            new Git(repository).checkout().setStartPoint(REMOTE_BRANCH).setCreateBranch(true)
                    .setName(LOCAL_BRANCH).setForce(true).call();
            Log.logInfoRB("GIT_FINISH", "download");
        } catch (Exception ex) {
            Log.logErrorRB("GIT_ERROR", "download", ex.getMessage());
            checkAndThrowException(ex);
        }
    }

    public void upload(File file, String commitMessage) throws NetworkException, Exception {
        if (readOnly) {
            // read-only - upload disabled
            Log.logInfoRB("GIT_READONLY");
            return;
        }

        boolean ok = true;
        Log.logInfoRB("GIT_START", "upload");
        try {
//            if (!isChanged(file)) {
//                Log.logInfoRB("GIT_FINISH", "upload(not changed)");
//                return;
//            }
            String filePattern = FileUtil.computeRelativePath(repository.getWorkTree(), file);
            new Git(repository).add().addFilepattern(filePattern).call();
            new Git(repository).commit().setMessage(commitMessage).call();
            Iterable<PushResult> results = new Git(repository).push().setRemote(REMOTE).add(LOCAL_BRANCH)
                    .call();
            int count = 0;
            for (PushResult r : results) {
                for (RemoteRefUpdate update : r.getRemoteUpdates()) {
                    count++;
                    if (update.getStatus() != RemoteRefUpdate.Status.OK) {
                        ok = false;
                    }
                }
            }
            if (count < 1) {
                ok = false;
            }
            Log.logInfoRB("GIT_FINISH", "upload");
        } catch (Exception ex) {
            Log.logErrorRB("GIT_ERROR", "upload", ex.getMessage());
            checkAndThrowException(ex);
        }
        if (!ok) {
            Log.logWarningRB("GIT_CONFLICT");
        }
    }

    private void checkAndThrowException(Exception ex) throws NetworkException, Exception {
        if (ex instanceof TransportException) {
            throw new NetworkException(ex);
        } else {
            throw ex;
        }
    }

    private static File getLocalRepositoryRoot(File path) {
        if (path == null) {
            return null;
        }
        File possibleControlDir = new File(path, ".git");
        if (possibleControlDir.exists() && possibleControlDir.isDirectory()) {
            return path;
        } else {
            // We need to call getAbsoluteFile() because "path" can be relative. In this case, we will have
            // "null" instead real parent directory.
            return getLocalRepositoryRoot(path.getAbsoluteFile().getParentFile());
        }
    }

    static ProgressMonitor gitProgress = new ProgressMonitor() {
        public void update(int completed) {
            System.out.println("update: " + completed);
        }

        public void start(int totalTasks) {
            System.out.println("start: " + totalTasks);
        }

        public boolean isCancelled() {
            return false;
        }

        public void endTask() {
            System.out.println("endTask");
        }

        public void beginTask(String title, int totalWork) {
            System.out.println("beginTask: " + title + " total: " + totalWork);
        }
    };

    /**
     * CredentialsProvider that will ask user for credentials when required,
     * and can store the credentials to plain text file.
      */
    private static class MyCredentialsProvider extends CredentialsProvider {
        
        GITRemoteRepository gitRemoteRepository;
        File credentialsFile;
        
        private Credentials credentials;

        public MyCredentialsProvider(GITRemoteRepository repo) {
            super();
            this.gitRemoteRepository = repo;
            if (repo != null) {
                credentialsFile = new File(gitRemoteRepository.localDirectory, "credentials.properties");
            }
        }
        
        public void setCredentials(Credentials credentials) {
            if (credentials == null) {
                return;
            }
            this.credentials = credentials.clone();
        }
        
        private void loadCredentials() {
            if (credentialsFile == null || !credentialsFile.exists()) {
                credentials = new Credentials();
                return;
            }
            try {
                credentials = Credentials.fromFile(credentialsFile);
            } catch (FileNotFoundException ex) {
                credentials = new Credentials();
            } catch (IOException ex) {
                ex.printStackTrace();
            }
        }
        
        private void saveCredentials() {
            if (credentials == null || credentialsFile == null || !credentials.saveAsPlainText) {
                return;
            }
            try {
                credentials.saveToPlainTextFile(credentialsFile);
            } catch (FileNotFoundException e) {
                Core.getMainWindow().displayErrorRB(e, "TEAM_ERROR_SAVE_CREDENTIALS", null, "TF_ERROR");
            } catch (IOException e) {
                Core.getMainWindow().displayErrorRB(e, "TEAM_ERROR_SAVE_CREDENTIALS", null, "TF_ERROR");
            }
        }

        @Override
        public boolean get(URIish uri, CredentialItem... items)
                throws UnsupportedCredentialItem {
            if (credentials == null) {
                loadCredentials();
            }
            boolean ok = false;
            //theoretically, username can be unknown, but in practice it is always set, so not requested.
            for (CredentialItem i : items) {
                if (i instanceof CredentialItem.Username) {
                    if (credentials.username==null) {
                        ok = askCredentials(uri.getUser());
                        if (!ok) {
                            throw new UnsupportedCredentialItem(uri, OStrings.getString("TEAM_CREDENTIALS_DENIED"));
                        }
                    }
                    ((CredentialItem.Username) i).setValue(credentials.username);
                    continue;
                } else if (i instanceof CredentialItem.Password) {
                    if (credentials.password==null) {
                        ok = askCredentials(uri.getUser());
                        if (!ok) {
                            throw new UnsupportedCredentialItem(uri, OStrings.getString("TEAM_CREDENTIALS_DENIED"));
                        }
                    }
                    ((CredentialItem.Password) i).setValue(credentials.password);
                    if (credentials.password != null) {
                        uri.setPass(new String(credentials.password));
                    }
                    continue;
                } else if (i instanceof CredentialItem.StringType) {
                    if (i.getPromptText().equals("Password: ")) {
                        if (credentials.password==null) {
                            if (!ok) {
                                ok = askCredentials(uri.getUser());
                                if (!ok) {
                                    throw new UnsupportedCredentialItem(uri, OStrings.getString("TEAM_CREDENTIALS_DENIED"));
                                }
                            }
                        }
                        ((CredentialItem.StringType) i).setValue(new String(credentials.password));
                        continue;
                    }
                } else if (i instanceof CredentialItem.YesNoType) {
                    //e.g.: The authenticity of host 'mygitserver' can't be established.
                    //RSA key fingerprint is e2:d3:84:d5:86:e7:68:69:a0:aa:a6:ad:a3:a0:ab:a2.
                    //Are you sure you want to continue connecting?
                    String promptText = i.getPromptText();
                    String promptedFingerprint = extractFingerprint(promptText);
                    if (promptedFingerprint.equals(credentials.fingerprint)) {
                        ((CredentialItem.YesNoType) i).setValue(true);
                        continue;
                    }
                    int choice = Core.getMainWindow().showConfirmDialog(promptText, null, JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE);
                    if (choice==JOptionPane.YES_OPTION) {
                        ((CredentialItem.YesNoType) i).setValue(true);
                        if (promptedFingerprint != null) {
                            credentials.fingerprint = promptedFingerprint;
                        }
                        saveCredentials();
                    } else {
                        ((CredentialItem.YesNoType) i).setValue(false);
                    }
                    continue;
                } else if (i instanceof CredentialItem.InformationalMessage) {
                    Core.getMainWindow().showMessageDialog(i.getPromptText());
                    continue;
                }
                throw new UnsupportedCredentialItem(uri, i.getClass().getName()
                        + ":" + i.getPromptText());
            }
            return true;
        }

        @Override
        public boolean isInteractive() {
            return true;
        }

        @Override
        public boolean supports(CredentialItem... items) {
            for (CredentialItem i : items) {
                if (i instanceof CredentialItem.Username)
                    continue;

                else if (i instanceof CredentialItem.Password)
                    continue;

                else
                    return false;
            }
            return true;
        }

        /**
         * shows dialog to ask for credentials, and stores credentials.
         * @return true when entered, false on cancel.
         */
        private boolean askCredentials(String usernameInUri) {
            TeamUserPassDialog userPassDialog = new TeamUserPassDialog(Core.getMainWindow().getApplicationFrame());
            userPassDialog.descriptionTextArea.setText(OStrings.getString(credentials.username==null ? "TEAM_USERPASS_FIRST" : "TEAM_USERPASS_WRONG"));
            //if username is already available in uri, then we will not be asked for an username, so we cannot change it.
            if (!StringUtil.isEmpty(usernameInUri)) {
                userPassDialog.setFixedUsername(usernameInUri);
            }
            userPassDialog.setVisible(true);
            if (userPassDialog.getReturnStatus() == TeamUserPassDialog.RET_OK) {
                credentials.username = userPassDialog.userText.getText();
                credentials.password = userPassDialog.getPasswordCopy();
                credentials.readOnly = userPassDialog.cbReadOnly.isSelected();
                if (gitRemoteRepository != null) {
                    gitRemoteRepository.setReadOnly(credentials.readOnly);
                }
                credentials.saveAsPlainText = userPassDialog.cbForceSavePlainPassword.isSelected();
                saveCredentials();
                return true;
            } else {
                return false;
            }
        }

        public void reset(URIish uri) {
            //reset is called after 5 authorization failures. After 3 resets, the transport gives up.
            credentials.clear();
        }

    }

    private static String extractFingerprint(String text) {
        Pattern p = Pattern.compile("The authenticity of host '.*' can't be established\\.\\nRSA key fingerprint is (([0-9a-f]{2}:){15}[0-9a-f]{2})\\.\\nAre you sure you want to continue connecting\\?");
        Matcher fingerprintMatcher = p.matcher(text);
        if (fingerprintMatcher.find()) {
            int start = fingerprintMatcher.start(1);
            int end = fingerprintMatcher.end(1);
            return text.substring(start, end);
        }
        return null;
    }
    

    /**
     * Determines whether or not the supplied URL represents a valid Git repository.
     * 
     * <p>Does the equivalent of <code>git ls-remote <i>url</i></code>.
     * 
     * @param url URL of supposed remote repository
     * @return true if repository appears to be valid, false otherwise
     */
    public static boolean isGitRepository(String url, Credentials credentials)
            throws AuthenticationException {
        // Heuristics to save some waiting time
        if (url.startsWith("svn://") || url.startsWith("svn+")) {
            return false;
        }
        try {
            if (credentials != null) {
                MyCredentialsProvider provider = new MyCredentialsProvider(null);
                provider.setCredentials(credentials);
                CredentialsProvider.setDefault(provider);
            }
            Collection<Ref> result = new LsRemoteCommand(null).setRemote(url).call();
            return !result.isEmpty();
        } catch (TransportException ex) {
            String message = ex.getMessage();
            if (message.endsWith("not authorized") || message.endsWith("Auth fail")
            		|| message.contains("Too many authentication failures")
            		|| message.contains("Authentication is required")) {
                throw new AuthenticationException(ex);
            }
            return false;
        } catch (GitAPIException ex) {
            throw new AuthenticationException(ex);
        } catch (JGitInternalException ex) {
            // Happens if the URL is a Subversion URL like svn://...
            return false;
        }
    }

    public static String guessRepoName(String url) {
        url = StringUtil.stripFromEnd(url, "/", ".git");
        return url.substring(url.lastIndexOf('/') + 1);
    }
}
