/*
 * JBoss, Home of Professional Open Source.
 * See the COPYRIGHT.txt file distributed with this work for information
 * regarding copyright ownership.  Some portions may be licensed
 * to Red Hat, Inc. under one or more contributor license agreements.
 * 
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 * 
 * This library 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
 * Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
 * 02110-1301 USA.
 */
package org.komodo.storage.git;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.nio.file.Files;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashSet;
import java.util.Properties;
import java.util.Set;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.RebaseResult;
import org.eclipse.jgit.api.RebaseResult.Status;
import org.eclipse.jgit.api.TransportConfigCallback;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.HttpTransport;
import org.eclipse.jgit.transport.JschConfigSessionFactory;
import org.eclipse.jgit.transport.OpenSshConfig;
import org.eclipse.jgit.transport.OpenSshConfig.Host;
import org.eclipse.jgit.transport.SshSessionFactory;
import org.eclipse.jgit.transport.SshTransport;
import org.eclipse.jgit.transport.Transport;
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.util.FS;
import org.komodo.spi.repository.DocumentType;
import org.komodo.spi.repository.Exportable;
import org.komodo.spi.repository.Repository.UnitOfWork;
import org.komodo.spi.storage.StorageConnector;
import org.komodo.spi.storage.StorageConnectorId;
import org.komodo.spi.storage.StorageNode;
import org.komodo.spi.storage.StorageParent;
import org.komodo.spi.storage.StorageTree;
import org.komodo.utils.ArgCheck;
import org.komodo.utils.FileUtils;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;

public class GitStorageConnector implements StorageConnector {

    /**
     * The path to the remote repository
     */
    public static final String REPO_PATH_PROPERTY = "repo-path-property";

    /**
     * The destination into which to clone the repository
     */
    public static final String REPO_DEST_PROPERTY = "repo-dest-property";

    /**
     * The branch to checkout
     */
    public static final String REPO_BRANCH_PROPERTY = "repo-branch-property";

    /**
     * The ssh private key
     */
    public static final String REPO_PRIVATE_KEY = "repo-private-key-property";

    /**
     * The ssh passphrase
     */
    public static final String REPO_PASSPHRASE = "repo-passphrase-property";

    /**
     * The known hosts key for this repository
     */
    public static final String REPO_KNOWN_HOSTS_ID = "repo-known-hosts-property";

    /**
     * The password property (used by http)
     */
    public static final String REPO_USERNAME = "repo-username-property";

    /**
     * The password property (used by both ssh and http)
     */
    public static final String REPO_PASSWORD = "repo-password-property";

    /**
     * The name of the author to be applied when writing a commit
     */
    public static final String AUTHOR_NAME_PROPERTY = "author-name-property";

    /**
     * The email of the author to be applied when writing a commit
     */
    public static final String AUTHOR_EMAIL_PROPERTY = "author-email-property";


    static final Set<Descriptor> DESCRIPTORS = new HashSet<Descriptor>();

    static {
        DESCRIPTORS.add(
                       new Descriptor(
                                     REPO_PATH_PROPERTY,
                                     true,
                                     "The URL location of the git repository to use as the destination storage."));
        DESCRIPTORS.add(
                       new Descriptor(
                                     REPO_DEST_PROPERTY,
                                     false,
                                     "The repository will be cloned to the path specified (on the server) by this property. " +
                                     "If not specified then a directory will be created beneath the server's temp directory"));
        DESCRIPTORS.add(
                       new Descriptor(
                                     REPO_BRANCH_PROPERTY,
                                     false,
                                     "The branch that should be checked out of the repository. If not specified then the " +
                                     " master branch is assumed."));
        DESCRIPTORS.add(
                       new Descriptor(
                                     StorageConnector.FILE_PATH_PROPERTY,
                                     true,
                                     "The relative (to the directory specified by \"repo-dest-property\") path of the file. " +
                                     "It is enough to specify only the name of the file."));
        DESCRIPTORS.add(
                        new Descriptor(
                                      REPO_KNOWN_HOSTS_ID,
                                      false,
                                      true,
                                      "If the repository is secured using ssh then a known hosts id is required to satisfy the first security check." +
                                      "This can be usually be found in the file ~/.ssh/known_hosts after manually connecting to the host using ssh"));
        DESCRIPTORS.add(
                        new Descriptor(
                                      REPO_PRIVATE_KEY,
                                      false,
                                      true,
                                      "If the repository is secured using ssh then a private key is required for authenticating " +
                                      "access to it."));
        DESCRIPTORS.add(
                        new Descriptor(
                                      REPO_PASSPHRASE,
                                      false,
                                      true,
                                      "If the repository is secured using ssh then a private key is required for authenticating. If in" + 
                                      "turn the private key is encrypted with a passphrase then this is also required"));
        DESCRIPTORS.add(
                        new Descriptor(
                                      REPO_USERNAME,
                                      false,
                                      "If the repository is secured using http then a username property will be required for authentication"));
        DESCRIPTORS.add(
                        new Descriptor(
                                      REPO_PASSWORD,
                                      false,
                                      true,
                                      "If the repository is secured using http (or password ssh rather than key-based ssh) then a password property"
                                      + "will be required for authentication"));
        DESCRIPTORS.add(
                        new Descriptor(
                                      AUTHOR_NAME_PROPERTY,
                                      false,
                                      "Specifies the name of the author for commits made during the write operation"));
        DESCRIPTORS.add(
                        new Descriptor(
                                      AUTHOR_EMAIL_PROPERTY,
                                      false,
                                      "Specifies the email address of the author for commits made during the write operation"));
    }

    /**
     * {@link JschConfigSessionFactory} that makes no use of local ssh credential files
     * such as ~/.ssh/id_rsa or ~/.ssh/.known_hosts. All required items are fed in via properties
     */
    private class CustomSshSessionFactory extends JschConfigSessionFactory {

        private JSch customJSch;

        //
        // Overridden to plug in the parameters rather than using credentials from
        // the host filesystem
        //
        @Override
        protected JSch createDefaultJSch(FS fs) throws JSchException {
            JSch jSch = new JSch();

            if (!parameters.containsKey(REPO_PRIVATE_KEY))
                return jSch;

            jSch.removeAllIdentity();

            if (parameters.containsKey(REPO_KNOWN_HOSTS_ID)) {
                String knownHost = parameters.getProperty(REPO_KNOWN_HOSTS_ID);
                ByteArrayInputStream stream = new ByteArrayInputStream(knownHost.getBytes());
                jSch.setKnownHosts(stream);
            }

            String prvKey = parameters.getProperty(REPO_PRIVATE_KEY);
            String passphrase = parameters.getProperty(REPO_PASSPHRASE);

            byte[] prvKeyBytes = prvKey != null ? prvKey.getBytes() : null;
            byte[] pphraseBytes = passphrase != null ? passphrase.getBytes() : null;

            jSch.addIdentity("Identity-" + prvKey.hashCode(), prvKeyBytes, null, pphraseBytes);
            return jSch;
        }

        //
        // Overridden to stop implementation using any credentials stored on the host filesystem
        //
        protected JSch getJSch(final OpenSshConfig.Host hc, FS fs) throws JSchException {
            if (customJSch == null)
                customJSch = createDefaultJSch(fs);

            return customJSch;
        }

        @Override
        protected void configure(Host host, Session session) {
            if (! parameters.containsKey(REPO_PASSWORD))
                return;

            //
            // Should set this if the ssh connection uses passwords rather than public/private key
            //
            session.setPassword(parameters.getProperty(REPO_PASSWORD));
        }
    }

    private class CustomTransportConfigCallback implements TransportConfigCallback {

        @Override
        public void configure(Transport transport) {
            if (transport instanceof SshTransport) {
                //
                // SSH requires an ssh session factory
                //
                SshTransport sshTransport = (SshTransport) transport;
                SshSessionFactory sshSessionFactory = new CustomSshSessionFactory();
                sshTransport.setSshSessionFactory(sshSessionFactory);
            } else if (transport instanceof HttpTransport) {
                //
                // HTTP requires a credentials provider
                //
                HttpTransport httpTransport = (HttpTransport) transport;
                String username = parameters.getProperty(REPO_USERNAME, "no-user-specified");
                String password = parameters.getProperty(REPO_PASSWORD, "no-password-specified");
                UsernamePasswordCredentialsProvider provider =
                        new UsernamePasswordCredentialsProvider(username, password);
                httpTransport.setCredentialsProvider(provider);
            }
        }
    }

    private final Properties parameters;

    private final StorageConnectorId id;

    private Git git;

    private final CustomTransportConfigCallback transportConfigCallback;

    private Set<String> filesForDisposal;

    public GitStorageConnector(Properties parameters) {
        ArgCheck.isNotNull(parameters);
        ArgCheck.isNotEmpty(parameters.getProperty(REPO_PATH_PROPERTY));

        this.parameters = parameters;

        this.id = new StorageConnectorId() {

            @Override
            public String type() {
                return StorageServiceImpl.STORAGE_ID;
            }

            @Override
            public String location() {
                return getPath();
            }
        };

        this.transportConfigCallback = new CustomTransportConfigCallback();
    }

    private void addToDisposalCache(File disposalFile) {
        if (filesForDisposal == null)
            filesForDisposal = new HashSet<String>();

        filesForDisposal .add(disposalFile.getAbsolutePath());
    }

    private void cloneRepository() throws Exception {
        File destination = new File(getDestination());
        File destGitDir = new File(destination, ".git");
        if (destGitDir.exists()) {
            git = Git.open(destination);
        } else {
            git = Git.cloneRepository()
                            .setURI(getPath())
                            .setDirectory(destination)
                            .setTransportConfigCallback(transportConfigCallback)
                            .call();
        }
    }

    @Override
    public StorageConnectorId getId() {
        return id;
    }

    @Override
    public Set<Descriptor> getDescriptors() {
        return DESCRIPTORS;
    }

    /**
     * @return repository path
     */
    public String getPath() {
        return parameters.getProperty(REPO_PATH_PROPERTY);
    }

    /**
     * @return repository branch
     */
    public String getBranch() {
        String branch = parameters.getProperty(REPO_BRANCH_PROPERTY);
        return branch != null ? branch : "master";
    }

    /**
     * @return the destination of the 'local' clone of the repository
     */
    public String getDestination() {
        String localRepoPath = parameters.getProperty(REPO_DEST_PROPERTY);
        if (localRepoPath != null)
            return localRepoPath;

        String repoPath = parameters.getProperty(REPO_PATH_PROPERTY);
        String dirName = "cloned-repo" + repoPath.hashCode();
        File repoDest = new File(FileUtils.tempDirectory(), dirName);
        repoDest.mkdir();

        localRepoPath = repoDest.getAbsolutePath();
        parameters.setProperty(REPO_DEST_PROPERTY, localRepoPath);

        return localRepoPath;
    }

    /**
     * @param parameters
     * @return the relative file path from the given parameters
     */
    public String getFilePath(Properties parameters) {
        return parameters.getProperty(FILE_PATH_PROPERTY);
    }

    @Override
    public boolean refresh() throws Exception {
        cloneRepository();
        ArgCheck.isNotNull(git);

        // Fetch latest information from remote
        git.fetch()
            .setTransportConfigCallback(transportConfigCallback)
            .call();

        // Ensure the original branch is checked out
        git.checkout()
            .setName(getBranch())
            .setForce(true)
            .call();

        // Rebase the branch against the remote branch
        RebaseResult rebaseResult = git.rebase().setUpstream("origin" + FORWARD_SLASH + getBranch()).call();
        Status status = rebaseResult.getStatus();
        return status.isSuccessful();
    }

    private String directory(String path, DocumentType documentType) {
        if (! path.endsWith(documentType.toString()))
            return path;

        return path.substring(0, path.lastIndexOf(DOT + documentType.toString()));
    }

    @Override
    public void write(Exportable artifact, UnitOfWork transaction, Properties parameters) throws Exception {
        ArgCheck.isNotNull(parameters);
        String destination = getFilePath(parameters);
        ArgCheck.isNotEmpty(destination);

        cloneRepository();

        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss_SSS");
        Date now = new Date();
        String timestamp = sdf.format(now);
        String artifactName = artifact.getName(transaction);

        //
        // Checkout a throw away branch for committing then
        // to be merged back onto main.
        //
        String branchName = artifactName + HYPHEN + timestamp;
        git.checkout()
            .setName(branchName)
            .setCreateBranch(true)
            .setForce(true)
            .setStartPoint(getBranch())
            .call();

        //
        // Write the file contents
        //
        byte[] contents = artifact.export(transaction, parameters);

        File destFile;
        DocumentType documentType = artifact.getDocumentType(transaction);
        if (DocumentType.ZIP.equals(documentType)) {
            //
            // Do not want to add binary zip files to a git repository
            //
            destination = directory(destination, documentType);
            destFile = new File(git.getRepository().getWorkTree(), destination);

            Files.createDirectories(destFile.toPath());
            ByteArrayInputStream byteStream = new ByteArrayInputStream(contents);
            FileUtils.zipExtract(byteStream, destFile);
        }
        else {
            destFile = new File(git.getRepository().getWorkTree(), destination);
            FileUtils.write(contents, destFile);
        }

        // Stage the file(s) for committing
        git.add()
            .addFilepattern(destination)
            .call();

        //
        // Commit the file(s)
        //
        String author = parameters.getProperty(GitStorageConnector.AUTHOR_NAME_PROPERTY, "anonymous");
        String authorEmail = parameters.getProperty(GitStorageConnector.AUTHOR_EMAIL_PROPERTY, "anon@komodo.org");
        RevCommit mergeCommit = git.commit()
                                                                .setAuthor(author, authorEmail)
                                                                .setCommitter(author, authorEmail)
                                                                .setMessage("Change to artifact " + artifactName + " at " + timestamp)
                                                                .call();

        //
        // Commit was successful
        // so checkout the main branch
        //
        git.checkout()
            .setName(getBranch())
            .setForce(true)
            .call();

        //
        // Ensure the later push would succeed by refreshing now
        //
        refresh();

        //
        // Merge the branch into the main branch
        //
        git.merge()
            .include(mergeCommit)
            .call();

        //
        // Push the change back to the remote
        //
        git.push()
            .setTransportConfigCallback(transportConfigCallback)
            .call();
    }

    @Override
    public InputStream read(Properties parameters) throws Exception {
        cloneRepository();

        String fileRef = getFilePath(parameters);
        ArgCheck.isNotNull(fileRef, "RelativeFileRef");

        File gitFile = new File(git.getRepository().getWorkTree(), fileRef);
        if (! gitFile.exists())
            throw new FileNotFoundException();

        FileInputStream fileStream;
        if (gitFile.isDirectory()) {
            File zipFileDest = File.createTempFile(gitFile.getName(), ZIP_SUFFIX);
            File zipFile = FileUtils.zipFromDirectory(gitFile, zipFileDest);
            addToDisposalCache(zipFile);
            fileStream = new FileInputStream(zipFile);
        } else {
            fileStream = new FileInputStream(gitFile);
        }

        return fileStream;
    }

    @Override
    public StorageTree<String> browse() throws Exception {
        cloneRepository();

        StorageTree<String> storageTree = new StorageTree<String>();

        Repository repository = git.getRepository();
        Ref head = repository.findRef(Constants.HEAD);

        RevWalk walk = null;
        try {
            walk = new RevWalk(repository);
            RevCommit commit = walk.parseCommit(head.getObjectId());
            RevTree tree = commit.getTree();

            TreeWalk treeWalk = null;
            try {
                treeWalk = new TreeWalk(repository);
                treeWalk.addTree(tree);
                treeWalk.setRecursive(false);

                StorageParent<String> parent = storageTree;
                int currentDepth = 0;
                while (treeWalk.next()) {
                    if (treeWalk.getDepth() < currentDepth) {
                        //
                        // Moved back up from a subtree so
                        // decrement currentDepth and
                        // change the parent to its parent
                        //
                        currentDepth = treeWalk.getDepth();
                        parent = parent.getParent();
                    }

                    StorageNode<String> child = parent.addChild(treeWalk.getNameString());

                    if (treeWalk.isSubtree()) {
                        //
                        // Entering a subtree so change
                        // parent to the child and
                        // increment the currentDepth
                        //
                        parent = child;
                        treeWalk.enterSubtree();
                        currentDepth = treeWalk.getDepth();
                    }
                }
            } finally {
                if (treeWalk != null) {
                    treeWalk.close();
                    treeWalk = null;
                }
            }
        } finally {
            if (walk != null) {
                walk.close();
                walk = null;
            }
        }

        return storageTree;
    }

    @Override
    public void dispose() {
        if (git != null)
            git.close();

        String destination = getDestination();
        File destFile = new File(destination);
        if (destFile.exists())
            FileUtils.removeDirectoryAndChildren(destFile);

        if (filesForDisposal != null) {
            for (String filePath : filesForDisposal) {
                File file = new File(filePath);
                if (! file.exists())
                    continue;

                file.delete();
            }

            filesForDisposal = null;
        }
    }
}
