/*
 * Copyright (C) 2010, Chris Aniszczyk <caniszczyk@gmail.com>
 * Copyright (C) 2011, Matthias Sohn <matthias.sohn@sap.com>
 * and other copyright owners as documented in the project's IP log.
 *
 * This program and the accompanying materials are made available
 * under the terms of the Eclipse Distribution License v1.0 which
 * accompanies this distribution, is reproduced below, and is
 * available at http://www.eclipse.org/org/documents/edl-v10.php
 *
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or
 * without modification, are permitted provided that the following
 * conditions are met:
 *
 * - Redistributions of source code must retain the above copyright
 *   notice, this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above
 *   copyright notice, this list of conditions and the following
 *   disclaimer in the documentation and/or other materials provided
 *   with the distribution.
 *
 * - Neither the name of the Eclipse Foundation, Inc. nor the
 *   names of its contributors may be used to endorse or promote
 *   products derived from this software without specific prior
 *   written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
 * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
 * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
 * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
 * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
 * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package org.eclipse.jgit.api;

import java.io.IOException;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.LinkedList;
import java.util.List;

import org.eclipse.jgit.api.CheckoutResult.Status;
import org.eclipse.jgit.api.errors.CheckoutConflictException;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.InvalidRefNameException;
import org.eclipse.jgit.api.errors.JGitInternalException;
import org.eclipse.jgit.api.errors.RefAlreadyExistsException;
import org.eclipse.jgit.api.errors.RefNotFoundException;
import org.eclipse.jgit.dircache.DirCache;
import org.eclipse.jgit.dircache.DirCacheCheckout;
import org.eclipse.jgit.dircache.DirCacheCheckout.CheckoutMetadata;
import org.eclipse.jgit.dircache.DirCacheEditor;
import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
import org.eclipse.jgit.dircache.DirCacheEntry;
import org.eclipse.jgit.dircache.DirCacheIterator;
import org.eclipse.jgit.errors.AmbiguousObjectException;
import org.eclipse.jgit.errors.UnmergedPathException;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.CoreConfig.EolStreamType;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.RefUpdate.Result;
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.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.PathFilterGroup;

/**
 * Checkout a branch to the working tree.
 * <p>
 * Examples (<code>git</code> is a {@link Git} instance):
 * <p>
 * Check out an existing branch:
 *
 * <pre>
 * git.checkout().setName(&quot;feature&quot;).call();
 * </pre>
 * <p>
 * Check out paths from the index:
 *
 * <pre>
 * git.checkout().addPath(&quot;file1.txt&quot;).addPath(&quot;file2.txt&quot;).call();
 * </pre>
 * <p>
 * Check out a path from a commit:
 *
 * <pre>
 * git.checkout().setStartPoint(&quot;HEAD&circ;&quot;).addPath(&quot;file1.txt&quot;).call();
 * </pre>
 *
 * <p>
 * Create a new branch and check it out:
 *
 * <pre>
 * git.checkout().setCreateBranch(true).setName(&quot;newbranch&quot;).call();
 * </pre>
 * <p>
 * Create a new tracking branch for a remote branch and check it out:
 *
 * <pre>
 * git.checkout().setCreateBranch(true).setName(&quot;stable&quot;)
 * 		.setUpstreamMode(SetupUpstreamMode.SET_UPSTREAM)
 * 		.setStartPoint(&quot;origin/stable&quot;).call();
 * </pre>
 *
 * @see <a
 *      href="http://www.kernel.org/pub/software/scm/git/docs/git-checkout.html"
 *      >Git documentation about Checkout</a>
 */
public class CheckoutCommand extends GitCommand<Ref> {

	/**
	 * Stage to check out, see {@link CheckoutCommand#setStage(Stage)}.
	 */
	public static enum Stage {
		/**
		 * Base stage (#1)
		 */
		BASE(DirCacheEntry.STAGE_1),

		/**
		 * Ours stage (#2)
		 */
		OURS(DirCacheEntry.STAGE_2),

		/**
		 * Theirs stage (#3)
		 */
		THEIRS(DirCacheEntry.STAGE_3);

		private final int number;

		private Stage(int number) {
			this.number = number;
		}
	}

	private String name;

	private boolean force = false;

	private boolean createBranch = false;

	private boolean orphan = false;

	private CreateBranchCommand.SetupUpstreamMode upstreamMode;

	private String startPoint = null;

	private RevCommit startCommit;

	private Stage checkoutStage = null;

	private CheckoutResult status;

	private List<String> paths;

	private boolean checkoutAllPaths;

	/**
	 * @param repo
	 */
	protected CheckoutCommand(Repository repo) {
		super(repo);
		this.paths = new LinkedList<>();
	}

	/**
	 * @throws RefAlreadyExistsException
	 *             when trying to create (without force) a branch with a name
	 *             that already exists
	 * @throws RefNotFoundException
	 *             if the start point or branch can not be found
	 * @throws InvalidRefNameException
	 *             if the provided name is <code>null</code> or otherwise
	 *             invalid
	 * @throws CheckoutConflictException
	 *             if the checkout results in a conflict
	 * @return the newly created branch
	 */
	@Override
	public Ref call() throws GitAPIException, RefAlreadyExistsException,
			RefNotFoundException, InvalidRefNameException,
			CheckoutConflictException {
		checkCallable();
		try {
			processOptions();
			if (checkoutAllPaths || !paths.isEmpty()) {
				checkoutPaths();
				status = new CheckoutResult(Status.OK, paths);
				setCallable(false);
				return null;
			}

			if (createBranch) {
				try (Git git = new Git(repo)) {
					CreateBranchCommand command = git.branchCreate();
					command.setName(name);
					if (startCommit != null)
						command.setStartPoint(startCommit);
					else
						command.setStartPoint(startPoint);
					if (upstreamMode != null)
						command.setUpstreamMode(upstreamMode);
					command.call();
				}
			}

			Ref headRef = repo.exactRef(Constants.HEAD);
			if (headRef == null) {
				// TODO Git CLI supports checkout from unborn branch, we should
				// also allow this
				throw new UnsupportedOperationException(
						JGitText.get().cannotCheckoutFromUnbornBranch);
			}
			String shortHeadRef = getShortBranchName(headRef);
			String refLogMessage = "checkout: moving from " + shortHeadRef; //$NON-NLS-1$
			ObjectId branch;
			if (orphan) {
				if (startPoint == null && startCommit == null) {
					Result r = repo.updateRef(Constants.HEAD).link(
							getBranchName());
					if (!EnumSet.of(Result.NEW, Result.FORCED).contains(r))
						throw new JGitInternalException(MessageFormat.format(
								JGitText.get().checkoutUnexpectedResult,
								r.name()));
					this.status = CheckoutResult.NOT_TRIED_RESULT;
					return repo.exactRef(Constants.HEAD);
				}
				branch = getStartPointObjectId();
			} else {
				branch = repo.resolve(name);
				if (branch == null)
					throw new RefNotFoundException(MessageFormat.format(
							JGitText.get().refNotResolved, name));
			}

			RevCommit headCommit = null;
			RevCommit newCommit = null;
			try (RevWalk revWalk = new RevWalk(repo)) {
				AnyObjectId headId = headRef.getObjectId();
				headCommit = headId == null ? null
						: revWalk.parseCommit(headId);
				newCommit = revWalk.parseCommit(branch);
			}
			RevTree headTree = headCommit == null ? null : headCommit.getTree();
			DirCacheCheckout dco;
			DirCache dc = repo.lockDirCache();
			try {
				dco = new DirCacheCheckout(repo, headTree, dc,
						newCommit.getTree());
				dco.setFailOnConflict(true);
				try {
					dco.checkout();
				} catch (org.eclipse.jgit.errors.CheckoutConflictException e) {
					status = new CheckoutResult(Status.CONFLICTS,
							dco.getConflicts());
					throw new CheckoutConflictException(dco.getConflicts(), e);
				}
			} finally {
				dc.unlock();
			}
			Ref ref = repo.findRef(name);
			if (ref != null && !ref.getName().startsWith(Constants.R_HEADS))
				ref = null;
			String toName = Repository.shortenRefName(name);
			RefUpdate refUpdate = repo.updateRef(Constants.HEAD, ref == null);
			refUpdate.setForceUpdate(force);
			refUpdate.setRefLogMessage(refLogMessage + " to " + toName, false); //$NON-NLS-1$
			Result updateResult;
			if (ref != null)
				updateResult = refUpdate.link(ref.getName());
			else if (orphan) {
				updateResult = refUpdate.link(getBranchName());
				ref = repo.exactRef(Constants.HEAD);
			} else {
				refUpdate.setNewObjectId(newCommit);
				updateResult = refUpdate.forceUpdate();
			}

			setCallable(false);

			boolean ok = false;
			switch (updateResult) {
			case NEW:
				ok = true;
				break;
			case NO_CHANGE:
			case FAST_FORWARD:
			case FORCED:
				ok = true;
				break;
			default:
				break;
			}

			if (!ok)
				throw new JGitInternalException(MessageFormat.format(JGitText
						.get().checkoutUnexpectedResult, updateResult.name()));


			if (!dco.getToBeDeleted().isEmpty()) {
				status = new CheckoutResult(Status.NONDELETED,
						dco.getToBeDeleted(),
						new ArrayList<>(dco.getUpdated().keySet()),
						dco.getRemoved());
			} else
				status = new CheckoutResult(new ArrayList<>(dco
						.getUpdated().keySet()), dco.getRemoved());

			return ref;
		} catch (IOException ioe) {
			throw new JGitInternalException(ioe.getMessage(), ioe);
		} finally {
			if (status == null)
				status = CheckoutResult.ERROR_RESULT;
		}
	}

	private String getShortBranchName(Ref headRef) {
		if (headRef.isSymbolic()) {
			return Repository.shortenRefName(headRef.getTarget().getName());
		}
		// Detached HEAD. Every non-symbolic ref in the ref database has an
		// object id, so this cannot be null.
		ObjectId id = headRef.getObjectId();
		if (id == null) {
			throw new NullPointerException();
		}
		return id.getName();
	}

	/**
	 * Add a single slash-separated path to the list of paths to check out. To
	 * check out all paths, use {@link #setAllPaths(boolean)}.
	 * <p>
	 * If this option is set, neither the {@link #setCreateBranch(boolean)} nor
	 * {@link #setName(String)} option is considered. In other words, these
	 * options are exclusive.
	 *
	 * @param path
	 *            path to update in the working tree and index (with
	 *            <code>/</code> as separator)
	 * @return {@code this}
	 */
	public CheckoutCommand addPath(String path) {
		checkCallable();
		this.paths.add(path);
		return this;
	}

	/**
	 * Add multiple slash-separated paths to the list of paths to check out. To
	 * check out all paths, use {@link #setAllPaths(boolean)}.
	 * <p>
	 * If this option is set, neither the {@link #setCreateBranch(boolean)} nor
	 * {@link #setName(String)} option is considered. In other words, these
	 * options are exclusive.
	 *
	 * @param p
	 *            paths to update in the working tree and index (with
	 *            <code>/</code> as separator)
	 * @return {@code this}
	 * @since 4.6
	 */
	public CheckoutCommand addPaths(List<String> p) {
		checkCallable();
		this.paths.addAll(p);
		return this;
	}

	/**
	 * Set whether to checkout all paths.
	 * <p>
	 * This options should be used when you want to do a path checkout on the
	 * entire repository and so calling {@link #addPath(String)} is not possible
	 * since empty paths are not allowed.
	 * <p>
	 * If this option is set, neither the {@link #setCreateBranch(boolean)} nor
	 * {@link #setName(String)} option is considered. In other words, these
	 * options are exclusive.
	 *
	 * @param all
	 *            <code>true</code> to checkout all paths, <code>false</code>
	 *            otherwise
	 * @return {@code this}
	 * @since 2.0
	 */
	public CheckoutCommand setAllPaths(boolean all) {
		checkoutAllPaths = all;
		return this;
	}

	/**
	 * Checkout paths into index and working directory
	 *
	 * @return this instance
	 * @throws IOException
	 * @throws RefNotFoundException
	 */
	protected CheckoutCommand checkoutPaths() throws IOException,
			RefNotFoundException {
		DirCache dc = repo.lockDirCache();
		try (RevWalk revWalk = new RevWalk(repo);
				TreeWalk treeWalk = new TreeWalk(repo,
						revWalk.getObjectReader())) {
			treeWalk.setRecursive(true);
			if (!checkoutAllPaths)
				treeWalk.setFilter(PathFilterGroup.createFromStrings(paths));
			if (isCheckoutIndex())
				checkoutPathsFromIndex(treeWalk, dc);
			else {
				RevCommit commit = revWalk.parseCommit(getStartPointObjectId());
				checkoutPathsFromCommit(treeWalk, dc, commit);
			}
		} finally {
			dc.unlock();
		}
		return this;
	}

	private void checkoutPathsFromIndex(TreeWalk treeWalk, DirCache dc)
			throws IOException {
		DirCacheIterator dci = new DirCacheIterator(dc);
		treeWalk.addTree(dci);

		String previousPath = null;

		final ObjectReader r = treeWalk.getObjectReader();
		DirCacheEditor editor = dc.editor();
		while (treeWalk.next()) {
			String path = treeWalk.getPathString();
			// Only add one edit per path
			if (path.equals(previousPath))
				continue;

			final EolStreamType eolStreamType = treeWalk.getEolStreamType();
			final String filterCommand = treeWalk
					.getFilterCommand(Constants.ATTR_FILTER_TYPE_SMUDGE);
			editor.add(new PathEdit(path) {
				@Override
				public void apply(DirCacheEntry ent) {
					int stage = ent.getStage();
					if (stage > DirCacheEntry.STAGE_0) {
						if (checkoutStage != null) {
							if (stage == checkoutStage.number)
								checkoutPath(ent, r, new CheckoutMetadata(
										eolStreamType, filterCommand));
						} else {
							UnmergedPathException e = new UnmergedPathException(
									ent);
							throw new JGitInternalException(e.getMessage(), e);
						}
					} else {
						checkoutPath(ent, r, new CheckoutMetadata(eolStreamType,
								filterCommand));
					}
				}
			});

			previousPath = path;
		}
		editor.commit();
	}

	private void checkoutPathsFromCommit(TreeWalk treeWalk, DirCache dc,
			RevCommit commit) throws IOException {
		treeWalk.addTree(commit.getTree());
		final ObjectReader r = treeWalk.getObjectReader();
		DirCacheEditor editor = dc.editor();
		while (treeWalk.next()) {
			final ObjectId blobId = treeWalk.getObjectId(0);
			final FileMode mode = treeWalk.getFileMode(0);
			final EolStreamType eolStreamType = treeWalk.getEolStreamType();
			final String filterCommand = treeWalk
					.getFilterCommand(Constants.ATTR_FILTER_TYPE_SMUDGE);
			editor.add(new PathEdit(treeWalk.getPathString()) {
				@Override
				public void apply(DirCacheEntry ent) {
					ent.setObjectId(blobId);
					ent.setFileMode(mode);
					checkoutPath(ent, r,
							new CheckoutMetadata(eolStreamType, filterCommand));
				}
			});
		}
		editor.commit();
	}

	private void checkoutPath(DirCacheEntry entry, ObjectReader reader,
			CheckoutMetadata checkoutMetadata) {
		try {
			DirCacheCheckout.checkoutEntry(repo, entry, reader, true,
					checkoutMetadata);
		} catch (IOException e) {
			throw new JGitInternalException(MessageFormat.format(
					JGitText.get().checkoutConflictWithFile,
					entry.getPathString()), e);
		}
	}

	private boolean isCheckoutIndex() {
		return startCommit == null && startPoint == null;
	}

	private ObjectId getStartPointObjectId() throws AmbiguousObjectException,
			RefNotFoundException, IOException {
		if (startCommit != null)
			return startCommit.getId();

		String startPointOrHead = (startPoint != null) ? startPoint
				: Constants.HEAD;
		ObjectId result = repo.resolve(startPointOrHead);
		if (result == null)
			throw new RefNotFoundException(MessageFormat.format(
					JGitText.get().refNotResolved, startPointOrHead));
		return result;
	}

	private void processOptions() throws InvalidRefNameException,
			RefAlreadyExistsException, IOException {
		if (((!checkoutAllPaths && paths.isEmpty()) || orphan)
				&& (name == null || !Repository
						.isValidRefName(Constants.R_HEADS + name)))
			throw new InvalidRefNameException(MessageFormat.format(JGitText
					.get().branchNameInvalid, name == null ? "<null>" : name)); //$NON-NLS-1$

		if (orphan) {
			Ref refToCheck = repo.exactRef(getBranchName());
			if (refToCheck != null)
				throw new RefAlreadyExistsException(MessageFormat.format(
						JGitText.get().refAlreadyExists, name));
		}
	}

	private String getBranchName() {
		if (name.startsWith(Constants.R_REFS))
			return name;

		return Constants.R_HEADS + name;
	}

	/**
	 * Specify the name of the branch or commit to check out, or the new branch
	 * name.
	 * <p>
	 * When only checking out paths and not switching branches, use
	 * {@link #setStartPoint(String)} or {@link #setStartPoint(RevCommit)} to
	 * specify from which branch or commit to check out files.
	 * <p>
	 * When {@link #setCreateBranch(boolean)} is set to <code>true</code>, use
	 * this method to set the name of the new branch to create and
	 * {@link #setStartPoint(String)} or {@link #setStartPoint(RevCommit)} to
	 * specify the start point of the branch.
	 *
	 * @param name
	 *            the name of the branch or commit
	 * @return this instance
	 */
	public CheckoutCommand setName(String name) {
		checkCallable();
		this.name = name;
		return this;
	}

	/**
	 * Specify whether to create a new branch.
	 * <p>
	 * If <code>true</code> is used, the name of the new branch must be set
	 * using {@link #setName(String)}. The commit at which to start the new
	 * branch can be set using {@link #setStartPoint(String)} or
	 * {@link #setStartPoint(RevCommit)}; if not specified, HEAD is used. Also
	 * see {@link #setUpstreamMode} for setting up branch tracking.
	 *
	 * @param createBranch
	 *            if <code>true</code> a branch will be created as part of the
	 *            checkout and set to the specified start point
	 * @return this instance
	 */
	public CheckoutCommand setCreateBranch(boolean createBranch) {
		checkCallable();
		this.createBranch = createBranch;
		return this;
	}

	/**
	 * Specify whether to create a new orphan branch.
	 * <p>
	 * If <code>true</code> is used, the name of the new orphan branch must be
	 * set using {@link #setName(String)}. The commit at which to start the new
	 * orphan branch can be set using {@link #setStartPoint(String)} or
	 * {@link #setStartPoint(RevCommit)}; if not specified, HEAD is used.
	 *
	 * @param orphan
	 *            if <code>true</code> a orphan branch will be created as part
	 *            of the checkout to the specified start point
	 * @return this instance
	 * @since 3.3
	 */
	public CheckoutCommand setOrphan(boolean orphan) {
		checkCallable();
		this.orphan = orphan;
		return this;
	}

	/**
	 * Specify to force the ref update in case of a branch switch.
	 *
	 * @param force
	 *            if <code>true</code> and the branch with the given name
	 *            already exists, the start-point of an existing branch will be
	 *            set to a new start-point; if false, the existing branch will
	 *            not be changed
	 * @return this instance
	 */
	public CheckoutCommand setForce(boolean force) {
		checkCallable();
		this.force = force;
		return this;
	}

	/**
	 * Set the name of the commit that should be checked out.
	 * <p>
	 * When checking out files and this is not specified or <code>null</code>,
	 * the index is used.
	 * <p>
	 * When creating a new branch, this will be used as the start point. If not
	 * specified or <code>null</code>, the current HEAD is used.
	 *
	 * @param startPoint
	 *            commit name to check out
	 * @return this instance
	 */
	public CheckoutCommand setStartPoint(String startPoint) {
		checkCallable();
		this.startPoint = startPoint;
		this.startCommit = null;
		checkOptions();
		return this;
	}

	/**
	 * Set the commit that should be checked out.
	 * <p>
	 * When creating a new branch, this will be used as the start point. If not
	 * specified or <code>null</code>, the current HEAD is used.
	 * <p>
	 * When checking out files and this is not specified or <code>null</code>,
	 * the index is used.
	 *
	 * @param startCommit
	 *            commit to check out
	 * @return this instance
	 */
	public CheckoutCommand setStartPoint(RevCommit startCommit) {
		checkCallable();
		this.startCommit = startCommit;
		this.startPoint = null;
		checkOptions();
		return this;
	}

	/**
	 * When creating a branch with {@link #setCreateBranch(boolean)}, this can
	 * be used to configure branch tracking.
	 *
	 * @param mode
	 *            corresponds to the --track/--no-track options; may be
	 *            <code>null</code>
	 * @return this instance
	 */
	public CheckoutCommand setUpstreamMode(
			CreateBranchCommand.SetupUpstreamMode mode) {
		checkCallable();
		this.upstreamMode = mode;
		return this;
	}

	/**
	 * When checking out the index, check out the specified stage (ours or
	 * theirs) for unmerged paths.
	 * <p>
	 * This can not be used when checking out a branch, only when checking out
	 * the index.
	 *
	 * @param stage
	 *            the stage to check out
	 * @return this
	 */
	public CheckoutCommand setStage(Stage stage) {
		checkCallable();
		this.checkoutStage = stage;
		checkOptions();
		return this;
	}

	/**
	 * @return the result, never <code>null</code>
	 */
	public CheckoutResult getResult() {
		if (status == null)
			return CheckoutResult.NOT_TRIED_RESULT;
		return status;
	}

	private void checkOptions() {
		if (checkoutStage != null && !isCheckoutIndex())
			throw new IllegalStateException(
					JGitText.get().cannotCheckoutOursSwitchBranch);
	}
}
