/*
 * Copyright (C) 2019, Google Inc. and others
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Distribution License v. 1.0 which is available at
 * https://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 */

package org.eclipse.jgit.internal.storage.reftable;

import org.eclipse.jgit.annotations.Nullable;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectIdRef;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.ProgressMonitor;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.RefDatabase;
import org.eclipse.jgit.lib.ReflogEntry;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.SymbolicRef;
import org.eclipse.jgit.revwalk.RevObject;
import org.eclipse.jgit.revwalk.RevTag;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.ReceiveCommand;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.locks.Lock;
import java.util.stream.Collectors;

import static org.eclipse.jgit.lib.Ref.Storage.NEW;
import static org.eclipse.jgit.lib.Ref.Storage.PACKED;

import static org.eclipse.jgit.transport.ReceiveCommand.Result.LOCK_FAILURE;
import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
import static org.eclipse.jgit.transport.ReceiveCommand.Result.OK;
import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_MISSING_OBJECT;
import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_NONFASTFORWARD;
import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
import static org.eclipse.jgit.transport.ReceiveCommand.Type.UPDATE_NONFASTFORWARD;

/**
 * {@link org.eclipse.jgit.lib.BatchRefUpdate} for Reftable based RefDatabase.
 */
public abstract class ReftableBatchRefUpdate extends BatchRefUpdate {
	private final Lock lock;

	private final ReftableDatabase refDb;

	private final Repository repository;

	/**
	 * Initialize.
	 *
	 * @param refdb
	 *            The RefDatabase
	 * @param reftableDb
	 *            The ReftableDatabase
	 * @param lock
	 *            A lock protecting the refdatabase's state
	 * @param repository
	 *            The repository on which this update will run
	 */
	protected ReftableBatchRefUpdate(RefDatabase refdb, ReftableDatabase reftableDb, Lock lock,
			Repository repository) {
		super(refdb);
		this.refDb = reftableDb;
		this.lock = lock;
		this.repository = repository;
	}

	@Override
	public void execute(RevWalk rw, ProgressMonitor pm, List<String> options) {
		List<ReceiveCommand> pending = getPending();
		if (pending.isEmpty()) {
			return;
		}
		if (options != null) {
			setPushOptions(options);
		}
		try {
			if (!checkObjectExistence(rw, pending)) {
				return;
			}
			// if we are here, checkObjectExistence might have flagged some problems
			// but the transaction is not atomic, so we should proceed with the other
			// pending commands.
			pending = getPending();
			if (!checkNonFastForwards(rw, pending)) {
				return;
			}
			pending = getPending();

			lock.lock();
			try {
				if (!checkExpected(pending)) {
					return;
				}
				pending = getPending();
				if (!checkConflicting(pending)) {
					return;
				}
				pending = getPending();
				if (!blockUntilTimestamps(MAX_WAIT)) {
					return;
				}

				List<Ref> newRefs = toNewRefs(rw, pending);
				applyUpdates(newRefs, pending);
				for (ReceiveCommand cmd : pending) {
					if (cmd.getResult() == NOT_ATTEMPTED) {
						// XXX this is a bug in DFS ?
						cmd.setResult(OK);
					}
				}
			} finally {
				lock.unlock();
			}
		} catch (IOException e) {
			pending.get(0).setResult(LOCK_FAILURE, "io error"); //$NON-NLS-1$
			ReceiveCommand.abort(pending);
		}
	}

	/**
	 * Implements the storage-specific part of the update.
	 *
	 * @param newRefs
	 *            the new refs to create
	 * @param pending
	 *            the pending receive commands to be executed
	 * @throws IOException
	 *             if any of the writes fail.
	 */
	protected abstract void applyUpdates(List<Ref> newRefs,
			List<ReceiveCommand> pending) throws IOException;

	private List<ReceiveCommand> getPending() {
		return ReceiveCommand.filter(getCommands(), NOT_ATTEMPTED);
	}

	private boolean checkObjectExistence(RevWalk rw,
			List<ReceiveCommand> pending) throws IOException {
		for (ReceiveCommand cmd : pending) {
			try {
				if (!cmd.getNewId().equals(ObjectId.zeroId())) {
					rw.parseAny(cmd.getNewId());
				}
			} catch (MissingObjectException e) {
				// ReceiveCommand#setResult(Result) converts REJECTED to
				// REJECTED_NONFASTFORWARD, even though that result is also
				// used for a missing object. Eagerly handle this case so we
				// can set the right result.
				cmd.setResult(REJECTED_MISSING_OBJECT);
				if (isAtomic()) {
					ReceiveCommand.abort(pending);
					return false;
				}
			}
		}
		return true;
	}

	private boolean checkNonFastForwards(RevWalk rw,
			List<ReceiveCommand> pending) throws IOException {
		if (isAllowNonFastForwards()) {
			return true;
		}
		for (ReceiveCommand cmd : pending) {
			cmd.updateType(rw);
			if (cmd.getType() == UPDATE_NONFASTFORWARD) {
				cmd.setResult(REJECTED_NONFASTFORWARD);
				if (isAtomic()) {
					ReceiveCommand.abort(pending);
					return false;
				}
			}
		}
		return true;
	}

	private boolean checkConflicting(List<ReceiveCommand> pending)
			throws IOException {
		TreeSet<String> added = new TreeSet<>();
		Set<String> deleted =
				pending.stream()
						.filter(cmd -> cmd.getType() == DELETE)
						.map(c -> c.getRefName())
						.collect(Collectors.toSet());

		boolean ok = true;
		for (ReceiveCommand cmd : pending) {
			if (cmd.getType() == DELETE) {
				continue;
			}

			String name = cmd.getRefName();
			if (refDb.isNameConflicting(name, added, deleted)) {
				if (isAtomic()) {
					cmd.setResult(
							ReceiveCommand.Result.REJECTED_OTHER_REASON, JGitText.get().transactionAborted);
				} else {
					cmd.setResult(LOCK_FAILURE);
				}

				ok = false;
			}
			added.add(name);
		}

		if (isAtomic()) {
			if (!ok) {
				pending.stream()
						.filter(cmd -> cmd.getResult() == NOT_ATTEMPTED)
						.forEach(cmd -> cmd.setResult(LOCK_FAILURE));
			}
			return ok;
		}

		for (ReceiveCommand cmd : pending) {
			if (cmd.getResult() == NOT_ATTEMPTED) {
				return true;
			}
		}

		return false;
	}

	private boolean checkExpected(List<ReceiveCommand> pending)
			throws IOException {
		for (ReceiveCommand cmd : pending) {
			if (!matchOld(cmd, refDb.exactRef(cmd.getRefName()))) {
				cmd.setResult(LOCK_FAILURE);
				if (isAtomic()) {
					ReceiveCommand.abort(pending);
					return false;
				}
			}
		}
		return true;
	}

	private static boolean matchOld(ReceiveCommand cmd, @Nullable Ref ref) {
		if (ref == null) {
			return AnyObjectId.isEqual(ObjectId.zeroId(), cmd.getOldId())
				&& cmd.getOldSymref() == null;
		} else if (ref.isSymbolic()) {
			return ref.getTarget().getName().equals(cmd.getOldSymref());
		}
		ObjectId id = ref.getObjectId();
		if (id == null) {
			id = ObjectId.zeroId();
		}
		return cmd.getOldId().equals(id);
	}

	/**
	 * Writes the refs to the writer, and calls finish.
	 *
	 * @param writer
	 *            the writer on which we should write.
	 * @param newRefs
	 *            the ref data to write..
	 * @param pending
	 *            the log data to write.
	 * @throws IOException
	 *            in case of problems.
	 */
	protected void write(ReftableWriter writer, List<Ref> newRefs,
			List<ReceiveCommand> pending) throws IOException {
		long updateIndex = refDb.nextUpdateIndex();
		writer.setMinUpdateIndex(updateIndex).setMaxUpdateIndex(updateIndex)
				.begin().sortAndWriteRefs(newRefs);
		if (!isRefLogDisabled()) {
			writeLog(writer, updateIndex, pending);
		}
	}

	private void writeLog(ReftableWriter writer, long updateIndex,
			List<ReceiveCommand> pending) throws IOException {
		Map<String, ReceiveCommand> cmds = new HashMap<>();
		List<String> byName = new ArrayList<>(pending.size());
		for (ReceiveCommand cmd : pending) {
			cmds.put(cmd.getRefName(), cmd);
			byName.add(cmd.getRefName());
		}
		Collections.sort(byName);

		PersonIdent ident = getRefLogIdent();
		if (ident == null) {
			ident = new PersonIdent(repository);
		}
		for (String name : byName) {
			ReceiveCommand cmd = cmds.get(name);
			if (isRefLogDisabled(cmd)) {
				continue;
			}
			String msg = getRefLogMessage(cmd);
			if (isRefLogIncludingResult(cmd)) {
				String strResult = toResultString(cmd);
				if (strResult != null) {
					msg = msg.isEmpty() ? strResult : msg + ": " + strResult; //$NON-NLS-1$
				}
			}
			writer.writeLog(name, updateIndex, ident, cmd.getOldId(),
					cmd.getNewId(), msg);
		}
	}

	private String toResultString(ReceiveCommand cmd) {
		switch (cmd.getType()) {
		case CREATE:
			return ReflogEntry.PREFIX_CREATED;
		case UPDATE:
			// Match the behavior of a single RefUpdate. In that case, setting
			// the force bit completely bypasses the potentially expensive
			// isMergedInto check, by design, so the reflog message may be
			// inaccurate.
			//
			// Similarly, this class bypasses the isMergedInto checks when the
			// force bit is set, meaning we can't actually distinguish between
			// UPDATE and UPDATE_NONFASTFORWARD when isAllowNonFastForwards()
			// returns true.
			return isAllowNonFastForwards() ? ReflogEntry.PREFIX_FORCED_UPDATE
					: ReflogEntry.PREFIX_FAST_FORWARD;
		case UPDATE_NONFASTFORWARD:
			return ReflogEntry.PREFIX_FORCED_UPDATE;
		default:
			return null;
		}
	}

	// Extracts and peels the refs out of the ReceiveCommands
	private static List<Ref> toNewRefs(RevWalk rw, List<ReceiveCommand> pending)
		throws IOException {
		List<Ref> refs = new ArrayList<>(pending.size());
		for (ReceiveCommand cmd : pending) {
			if (cmd.getResult() != NOT_ATTEMPTED) {
				continue;
			}

			String name = cmd.getRefName();
			ObjectId newId = cmd.getNewId();
			String newSymref = cmd.getNewSymref();
			if (AnyObjectId.isEqual(ObjectId.zeroId(), newId)
				&& newSymref == null) {
				refs.add(new ObjectIdRef.Unpeeled(NEW, name, null));
				continue;
			} else if (newSymref != null) {
				refs.add(new SymbolicRef(name,
					new ObjectIdRef.Unpeeled(NEW, newSymref, null)));
				continue;
			}

			RevObject obj = rw.parseAny(newId);
			RevObject peel = null;
			if (obj instanceof RevTag) {
				peel = rw.peel(obj);
			}
			if (peel != null) {
				refs.add(new ObjectIdRef.PeeledTag(PACKED, name, newId,
					peel.copy()));
			} else {
				refs.add(new ObjectIdRef.PeeledNonTag(PACKED, name, newId));
			}
		}
		return refs;
	}
}
