/**
 * This file is part of Eclipse Steady.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 * SPDX-License-Identifier: Apache-2.0
 * SPDX-FileCopyrightText: Copyright (c) 2018-2020 SAP SE or an SAP affiliate company and Eclipse Steady contributors
 */
package org.eclipse.steady.git;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.Logger;

import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.NoHeadException;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.diff.RawTextComparator;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.RepositoryBuilder;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.PathFilter;
import org.eclipse.jgit.util.io.DisabledOutputStream;
import org.eclipse.steady.shared.util.StopWatch;
import org.eclipse.steady.vcs.FileChange;
import org.eclipse.steady.vcs.IVCSClient;
import org.eclipse.steady.vcs.RepoMismatchException;

/*
 * JGit examples taken from:
 * https://github.com/centic9/jgit-cookbook
 */

/**
 * <p>GitClient class.</p>
 */
public class GitClient implements IVCSClient {

  private static final int RANDOM_ID_LENGTH = 8;
  private static final Logger log = org.apache.logging.log4j.LogManager.getLogger(GitClient.class);
  private static final String TYPE = "GIT";

  private String id;
  //    private final String id = new Double( Math.random() )
  //    .toString()
  //    .substring( 0, RANDOM_ID_LENGTH );
  private Path workDir = null;
  private String url = null;
  private Repository repository = null;

  /**
   * <p>getType.</p>
   *
   * @return a {@link java.lang.String} object.
   */
  public String getType() {
    return GitClient.TYPE;
  }

  /*
   * setup, almost a constructor
   *
   * @param _repoUrl the url from which the repository is to be cloned
   * @param _repoPath where to put the clone in the local filesystem
   */
  private Repository setup(String _repoUrl, Path _repoPath) throws RepoMismatchException {
    Repository repository = null;
    try {
      FileRepositoryBuilder builder = new FileRepositoryBuilder();
      builder.addCeilingDirectory(new File("/tmp/")); // TODO: restrict this
      builder.findGitDir(_repoPath.toFile());

      if (builder.getGitDir() == null) {
        GitClient.log.info("Create dir [" + _repoPath + "] for GIT repo [" + _repoUrl + "]");
        repository = builder.setGitDir(_repoPath.toFile()).readEnvironment().findGitDir().build();

      } else {
        repository = getRepositoryFromPath(_repoPath.toString());
      }
      Git git = new Git(repository);
      // no previous repository found
      // only clone if this has not been done yet
      if (repository.getRef("HEAD") == null) {
        git.cloneRepository().setURI(_repoUrl).setDirectory(_repoPath.toFile()).call();

      } else {
        GitClient.log.info(
            "Found existing dir ["
                + builder.getGitDir().toPath().toAbsolutePath()
                + "] for GIT repo ["
                + _repoUrl
                + "], trying to fetch to update it");
        git.fetch().call();
        git.pull().call();
      }
    }
    // read this:
    // http://www.codeaffine.com/2014/09/22/access-git-repository-with-jgit/
    catch (org.eclipse.jgit.api.errors.TransportException e) {
      GitClient.log.error("Invalid GIT repository at " + _repoUrl);
      this.cleanup();
      throw new RepoMismatchException(this, this.url, e);
    } catch (org.eclipse.jgit.api.errors.InvalidRemoteException e) {
      GitClient.log.error("Invalid remote for GIT repository at " + _repoUrl);
      this.cleanup();
      throw new RepoMismatchException(this, this.url, e);
    } catch (IOException e) {
      GitClient.log.error(
          "IO exception while cloning GIT repository [" + _repoUrl + "]: " + e.getMessage(), e);
      throw new RepoMismatchException(this, this.url, e);
    } catch (NoHeadException e) {
      GitClient.log.error(
          "NoHeadException while cloning GIT repository [" + _repoUrl + "]: " + e.getMessage(), e);
      throw new RepoMismatchException(this, this.url, e);
    } catch (GitAPIException e) {
      GitClient.log.error(
          "GitAPIException while cloning GIT repository [" + _repoUrl + "]: " + e.getMessage(), e);
      // GitClient.log.error( "Could not connect to GIT repository at " + _repoUrl );
      this.cleanup();
      throw new RepoMismatchException(this, this.url, e);
    } catch (Exception e) {
      GitClient.log.error(
          "Unknown exception while cloning GIT repository [" + _repoUrl + "]: " + e.getMessage(),
          e);
      throw new RepoMismatchException(this, this.url, e);
    }

    return (repository);
  }

  /**
   * Deletes temporary folders
   */
  public void cleanup() {

    // Path path = this.workDir;
    // GitClient.log.info( "Deleting recursivey : " + path );

    /**
     * NOTE: all this can be easily done using Apache's commons-io:
     *
     *    FileUtils.deleteDirectory(new File("directory"));
     */
    try {
      FileUtils.deleteDirectory(this.workDir.toFile());
      GitClient.log.error("Deleted temp. directory [" + this.workDir + "]");
    } catch (IOException e) {
      GitClient.log.error(
          "Error while deleting temp. directory '" + this.workDir + "': " + e.getMessage());
    }
  }

  /**
   * {@inheritDoc}
   *
   *  NOTE: this method does much more than just setting the repository URL!
   *  It effectively invokes the (private) pseudo-constructor "setup()"
   *
   *  It is like this because the API mandates so (IVCSClient interface)
   */
  public void setRepoUrl(URL _u) throws RepoMismatchException {
    if (_u == null) {
      throw new IllegalArgumentException("Invalid url: " + _u);
    }

    this.url = _u.toString();
    // fix for: VULAS-67
    // NOTE: this.id is now used to create the folder in which to clone the repository locally
    this.id = _u.getHost() + _u.getPath().replace("/", "-");

    if (this.workDir == null) {
      this.setWorkDir(null);
    }

    try {
      // all the magic happens in setup (pseudo-constructor)
      // post:
      // - this.repository is created and setup
      // - the git repos is cloned and available in this.workDir
      this.repository = this.setup(this.url, this.workDir);
    } catch (Exception e) {
      throw new RepoMismatchException(this, this.url, e);
      // "Cannot create GIT repository from URL '" + tmp + "': " + e.getMessage());
      /* e.printStackTrace(); */
    }
  }

  /*
   *
   */
  /** {@inheritDoc} */
  public void setWorkDir(Path _dir) {
    if (_dir != null) {
      this.workDir = _dir;
      return;
    }

    try {
      /* this.workDir = java.nio.file.Files.createTempDirectory( "patcha_" + this.id ); */
      this.workDir =
          java.nio.file.Files.createDirectories(new File("/tmp/patcha_" + this.id).toPath());
    } catch (IOException e) {
      throw new IllegalStateException("Unable to create work directory", e);
    }
  }

  /**
   * {@inheritDoc}
   *
   * Performs a search in the repository root.
   *
   * This returns ALL the commits whose commit msg contains _str
   * (only those younger than _asOf)
   */
  public Map<String, String> searchCommitLog(String _textToSearch, Date _asOf) {
    final Map<String, String> hits = new HashMap<String, String>();

    try {

      Repository repository = this.getRepositoryFromPath(null);

      Git git = new Git(repository);
      RevWalk walk = new RevWalk(repository);

      // BROKEN
      /* if ( _asOf != null ) { */
      /* RevFilter filter = CommitTimeRevFilter.after( _asOf ); */
      /* walk.setRevFilter( filter ); */
      /* } */

      RevCommit commit = null;

      Iterable<RevCommit> logs = git.log().call();
      Iterator<RevCommit> i = logs.iterator();

      String commitId = null;
      String commitMsg = null;
      int commitTimeStamp;
      Date commitDate;

      if (_asOf != null) GitClient.log.info("Search commits after [" + _asOf + "]");

      while (i.hasNext()) {
        commit = walk.parseCommit(i.next());
        if (commit != null) {
          commitId = commit.getName();
          commitMsg = commit.getFullMessage();
          commitDate = new Date((long) commit.getCommitTime() * 1000l);

          // If no date is given, we only search in the commit message
          if (commitMsg.contains(_textToSearch) && (_asOf == null || commitDate.after(_asOf))) {
            GitClient.log.info(
                "Found commit [id="
                    + commitId
                    + ", date="
                    + commitDate
                    + ", tst="
                    + commit.getCommitTime()
                    + "]: "
                    + commitMsg);
            hits.put(commitId, commitMsg);
          }
        }
      }

    } catch (Exception e) {
      GitClient.log.error("Error while searching commit log: " + e.getMessage());
      // e.printStackTrace();
    }
    return hits;
  }

  /*
   * Returns a Git repository object built from the content of a previously
   * cloned local Git repository available locally at _path
   */
  private Repository getRepositoryFromPath(String _path) {

    Repository repository = null;

    try {
      if (_path == null) {
        _path = this.workDir.toString() + "/.git";
      } else {
        _path += "/.git";
      }

      RepositoryBuilder builder = new RepositoryBuilder();
      repository = builder.setGitDir(new File(_path)).readEnvironment().findGitDir().build();
    } catch (Exception e) {
      e.printStackTrace();
    }

    return repository;
  }

  /*
   *  The result contains pairs (rev_no, commit_msg)
   */
  /** {@inheritDoc} */
  public Map<String, String> getCommitLogEntries(Set<String> _revs) {
    final Map<String, String> hits = new HashMap<String, String>();
    if (!_revs.isEmpty()) {
      try {
        Repository repository = this.getRepositoryFromPath(null);

        RevWalk walk = new RevWalk(repository);
        // walk.setRevFilter(RevFilter);
        RevCommit commit = null;

        Git git = new Git(repository);
        Iterable<RevCommit> logs = git.log().call();
        Iterator<RevCommit> i = logs.iterator();

        String commitId = null;
        String commitMsg = null;

        // iterate over all commits
        while (i.hasNext()) {
          commit = walk.parseCommit(i.next());

          commitId = commit.getName();
          commitMsg = commit.getFullMessage();

          // iterate over all revisions to search for
          for (String sid : _revs) {
            if (sid.contains(":")) {
              sid = sid.substring(0, sid.indexOf(":") - 1);
            }
            if (!sid.equals("") && sid.equals(commitId)) {
              hits.put(commitId, commitMsg);
              continue;
            }
          }
        }
      } catch (UnknownHostException e) {
        GitClient.log.error("Proxy issues?");
        e.printStackTrace();
      } catch (IOException ioe) {
        GitClient.log.error("Something went wrong with the I/O");
        ioe.printStackTrace();
      } catch (GitAPIException ge) {
        GitClient.log.error("Something went wrong with the GIT API");
        ge.printStackTrace();
      }
    }
    return hits;
  }
  /*
   * Important information here:
   * http://www.eclipse.org/forums/index.php/t/213979/
   * https://github.com/centic9/jgit-cookbook/blob/master/src/main/java/org/dstadler/jgit/api/ReadFileFromCommit.java
   */
  /** {@inheritDoc} */
  public Set<FileChange> getFileChanges(String _rev) {
    final StopWatch sw =
        new StopWatch(
                "Get file changes for revision ["
                    + _rev.substring(0, (_rev.length() > 8) ? 8 : _rev.length())
                    + "]")
            .start();

    String branch = "";
    if (_rev.contains(":")) {
      String[] rev_branch = _rev.split(":");
      _rev = rev_branch[0];
      branch = rev_branch[1];
    }

    // TODO: use this.repository instead
    Repository repository = this.getRepositoryFromPath(null);
    final Set<FileChange> changes = new HashSet<FileChange>();
    try {
      RevWalk rw = new RevWalk(repository);
      ObjectId commitId = repository.resolve(_rev);
      RevCommit commit = rw.parseCommit(commitId);

      if (commit.getParentCount() > 1) {
        GitClient.log.info(
            "[WARNING] Found multiple parents, I will only consider one (the first I can get...).");
      }

      RevCommit parent = null;
      if (commit.getParent(0) != null) {
        parent = rw.parseCommit(commit.getParent(0).getId());
      }

      DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE);
      df.setRepository(repository);
      df.setDiffComparator(RawTextComparator.DEFAULT);
      df.setDetectRenames(true);
      List<DiffEntry> diffs = df.scan(parent.getTree(), commit.getTree());

      File oldFile, newFile;
      DiffEntry.ChangeType changeType;
      String newPath = null;
      String oldPath = null;

      for (DiffEntry entry : diffs) {

        oldFile = null;
        newFile = null;

        newPath = entry.getNewPath();
        oldPath = entry.getOldPath();

        String parentRev = parent.getName();

        // Checkout file(s), depending on the modification type
        switch (entry.getChangeType()) {
          case MODIFY:
            GitClient.log.info("[Modified] " + branch + newPath);
            oldFile = this.checkoutFile(parentRev, oldPath);
            newFile = this.checkoutFile(_rev, newPath);

            if (oldFile != null || newFile != null) {
              changes.add(new FileChange(this.url, branch + newPath, oldFile, newFile));
            }
            break;
          case ADD:
            GitClient.log.info("[Created] " + branch + newPath);
            oldFile = this.checkoutFile(parentRev, oldPath);
            newFile = this.checkoutFile(_rev, newPath);

            if (oldFile != null || newFile != null) {
              changes.add(new FileChange(this.url, branch + newPath, oldFile, newFile));
            }
            break;
          case DELETE:
            GitClient.log.info("[Deleted] " + branch + newPath);
            oldFile = this.checkoutFile(parentRev, oldPath);
            newFile = this.checkoutFile(_rev, newPath);

            if (oldFile != null || newFile != null) {
              changes.add(new FileChange(this.url, branch + oldPath, oldFile, newFile));
            }
            break;
          case COPY:
            GitClient.log.info("[Copied] " + branch + newPath);
            oldFile = this.checkoutFile(parentRev, oldPath);
            newFile = this.checkoutFile(_rev, newPath);

            if (oldFile != null || newFile != null) {
              changes.add(new FileChange(this.url, branch + newPath, oldFile, newFile));
            }
            break;
          case RENAME:
            GitClient.log.info("[Moved] " + branch + newPath);
            oldFile = this.checkoutFile(parentRev, oldPath);
            newFile = this.checkoutFile(_rev, newPath);

            if (oldFile != null || newFile != null) {
              changes.add(new FileChange(this.url, branch + oldPath, oldFile, null));
              changes.add(new FileChange(this.url, branch + newPath, null, newFile));
            }
            break;
          default:
            GitClient.log.warn("[UNKNOWN CHANGE TYPE] " + branch + newPath);
            oldFile = null;
            newFile = null;
            break;
        }

        // Also try to checkout __init__.py files (which are needed to build the construct ID)
        this.checkoutPyInits(parentRev, oldPath);
        this.checkoutPyInits(_rev, newPath);

        // TODO what to do with directories?
      }
      sw.stop();
    } catch (Exception e) {
      sw.stop(e);
      GitClient.log.error("Error while checking out files: " + e.getMessage());
    }
    return changes;
  }

  /**
   * {@inheritDoc}
   *
   * TODO: To be implemented
   */
  public Map<String, String> listEntries(String path, String _asof, String _until) {
    Map<String, String> l = new HashMap<String, String>();
    return l;
  }

  private void checkoutPyInits(String _rev_branch, String _path) {
    // Only check for Python files
    if (!_path.endsWith("py")) return;

    // Checkout init in current dir
    Path py_file = Paths.get(_path);
    Path init_file = null;
    if (py_file.getParent() == null) init_file = Paths.get("__init__.py");
    else init_file = py_file.getParent().resolve("__init__.py");

    boolean exists = false;
    try {
      final File f = checkoutIfNotExists(_rev_branch, toGitPath(init_file));
      exists = f != null;
    } catch (IllegalStateException ise) {
      log.warn("[" + init_file.toString() + "] does not exist in remote repo");
      exists = false;
    }

    // Checkout init in parent dir
    if (exists && init_file.getParent() != null) {
      Path parent_init = null;
      if (init_file.getParent().getParent() == null) parent_init = Paths.get("__init__.py");
      else parent_init = init_file.getParent().getParent().resolve("__init__.py");
      checkoutPyInits(_rev_branch, parent_init.toString());
    }
  }

  private String toGitPath(Path _p) {
    final StringBuffer b = new StringBuffer();
    for (int i = 0; i < _p.getNameCount(); i++) {
      b.append(_p.getName(i));
      if (i < _p.getNameCount() - 1) b.append("/");
    }
    return b.toString();
  }

  /**
   * Checks whether a file with the given path already exists in the local copy of the repo.
   * If not, it attempts to check it out from the remote repo.
   * @param _rev_branch
   * @param _path
   * @return
   */
  private File checkoutIfNotExists(String _rev_branch, String _path) throws IllegalStateException {
    final String[] rev_branch = splitRevBranch(_rev_branch);
    File file = Paths.get(this.workDir.toString(), rev_branch[0], _path).toFile();
    if (!file.exists()) {
      log.info("Starting checkout of [" + _path + "]");
      file = this.checkoutFile(_rev_branch, _path);
    } else {
      log.info("[" + _path + "] already exists, no checkout needed");
    }
    return file;
  }

  private static String[] splitRevBranch(String _string) {
    final int idx = _string.indexOf(':');
    if (idx == -1) {
      return new String[] {_string, ""};
    } else {
      return new String[] {_string.substring(0, idx - 1), _string.substring(idx)};
    }
  }

  /**
   * {@inheritDoc}
   *
   * Check out the file at path _path (relative to the repository root) at
   * revision _rev
   */
  public File checkoutFile(String _rev_branch, String _path) throws IllegalStateException {
    if (_path.equals("/dev/null")) return null;

    try {
      Repository repository = this.getRepositoryFromPath(null);
      //    Repository repository = this.repository;
      //

      // Split revision and potential branch info
      final String[] rev_branch = splitRevBranch(_rev_branch);
      /*if(_rev.contains(":")){
      	_rev= _rev.substring(0,_rev.indexOf(":")-1);
      }*/

      ObjectId commitId = repository.resolve(rev_branch[0]);
      // a RevWalk allows to walk over commits based on some filtering that is defined
      RevWalk revWalk = new RevWalk(repository);
      RevCommit commit = revWalk.parseCommit(commitId);
      // and using commit's tree find the path
      RevTree tree = commit.getTree();
      GitClient.log.info("Having tree: " + tree);
      // now try to find a specific file
      TreeWalk treeWalk = new TreeWalk(repository);
      treeWalk.addTree(tree);
      treeWalk.setRecursive(true);
      treeWalk.setFilter(PathFilter.create(_path));
      if (!treeWalk.next()) {
        throw new IllegalStateException("Did not find expected file '" + _path + "'");
      }
      ObjectId objectId = treeWalk.getObjectId(0);
      ObjectLoader loader = repository.open(objectId);
      // and then one can the loader to read the file

      File targetFile = new File(this.workDir.toString() + "/" + rev_branch[0] + "/" + _path);
      File parentDir = targetFile.getParentFile(); // to get the parent dir
      java.nio.file.Files.createDirectories(parentDir.toPath());

      try (final OutputStream fos = new FileOutputStream(targetFile)) {
        /* loader.copyTo( System.out ); */
        loader.copyTo(fos);
      }
      revWalk.dispose();
      repository.close();

      return targetFile;

    } catch (MissingObjectException e) {
      // BUG - FIX: not sure why this happens
      GitClient.log.error("Object not found for path [" + _path + "]");
      GitClient.log.error("Maybe this is the content of a submodule? Skipping....");
    } catch (IllegalStateException e) {
      throw e;
    } catch (Exception e) {
      log.error(e);
    }

    return null;
  }

  /*
   *
   */
  /** {@inheritDoc} */
  public Path checkout(String _rev) {
    // checks out a specific _revision and puts it in a temp folder (Path)
    GitClient.log.error("GitClient::checkout(String) not implemented yet.");
    return null;
  }

  /**
   * <p>getRepoRelativePath.</p>
   *
   * @return a {@link java.lang.String} object.
   */
  public String getRepoRelativePath() {
    /*
    String rel_path = null;
    Collection<SVNDirEntry> SVNentry = this.rootRepo.getDir("tags/", -1, null,(Collection<SVNDirEntry>) null);
    Iterator<SVNDirEntry> iterator = SVNentry.iterator();
    if (iterator.hasNext()) {
    	SVNDirEntry entry = (SVNDirEntry) iterator.next();
    	// remove the root repository part from the whole url (object: SVNDirEntry)
    	rel_path = entry.getURL().toString().replaceAll(entry.getRepositoryRoot().toString(), "");
    }else{
    	SvnClient.log.error("[Error] while getting the relative path for tag directory");
    }
    rel_path = rel_path.substring(0, rel_path.lastIndexOf('/'));
    return rel_path;
     */

    // TODO: To be tested
    return url.toString().replace(repository.toString(), "");
  }

  /** {@inheritDoc} */
  public long getRevisionTimeStamp(String _rev) {
    if (_rev.contains(":")) {
      _rev = _rev.substring(0, _rev.indexOf(":") - 1);
    }
    long timeStamp = 0;
    RevCommit commit = null;
    try {
      Repository repository = this.getRepositoryFromPath(null);
      //    Repository repository = this.repository;

      ObjectId commitId = repository.resolve(_rev);
      // a RevWalk allows to walk over commits based on some filtering that is defined
      RevWalk revWalk = new RevWalk(repository);
      commit = revWalk.parseCommit(commitId);
      PersonIdent committerIdent = commit.getCommitterIdent();
      timeStamp = committerIdent.getWhen().getTime();

    } catch (MissingObjectException e) {
      // BUG - FIX: not sure why this happens
      // GitClient.log.error( "Object not found for path:" + _path );
      GitClient.log.error("Maybe this is the content of a submodule? Skipping....");
    } catch (Exception e) {
      e.printStackTrace();
    }

    // return commit.getCommitTime();
    return timeStamp;
  }
}
