/*
 * Copyright 2013 gitblit.com.
 *
 * 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.
 */
package com.gitblit.tickets;

import java.io.IOException;
import java.text.MessageFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.StoredConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.gitblit.IStoredSettings;
import com.gitblit.Keys;
import com.gitblit.extensions.TicketHook;
import com.gitblit.manager.IManager;
import com.gitblit.manager.INotificationManager;
import com.gitblit.manager.IPluginManager;
import com.gitblit.manager.IRepositoryManager;
import com.gitblit.manager.IRuntimeManager;
import com.gitblit.manager.IUserManager;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.TicketModel;
import com.gitblit.models.TicketModel.Attachment;
import com.gitblit.models.TicketModel.Change;
import com.gitblit.models.TicketModel.Field;
import com.gitblit.models.TicketModel.Patchset;
import com.gitblit.models.TicketModel.PatchsetType;
import com.gitblit.models.TicketModel.Status;
import com.gitblit.models.TicketModel.TicketLink;
import com.gitblit.tickets.TicketIndexer.Lucene;
import com.gitblit.utils.DeepCopier;
import com.gitblit.utils.DiffUtils;
import com.gitblit.utils.JGitUtils;
import com.gitblit.utils.DiffUtils.DiffStat;
import com.gitblit.utils.StringUtils;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;

/**
 * Abstract parent class of a ticket service that stubs out required methods
 * and transparently handles Lucene indexing.
 *
 * @author James Moger
 *
 */
public abstract class ITicketService implements IManager {

	public static final String SETTING_UPDATE_DIFFSTATS = "migration.updateDiffstats";

	private static final String LABEL = "label";

	private static final String MILESTONE = "milestone";

	private static final String STATUS = "status";

	private static final String COLOR = "color";

	private static final String DUE = "due";

	private static final String DUE_DATE_PATTERN = "yyyy-MM-dd";

	/**
	 * Object filter interface to querying against all available ticket models.
	 */
	public interface TicketFilter {

		boolean accept(TicketModel ticket);
	}

	protected final Logger log;

	protected final IStoredSettings settings;

	protected final IRuntimeManager runtimeManager;

	protected final INotificationManager notificationManager;

	protected final IUserManager userManager;

	protected final IRepositoryManager repositoryManager;

	protected final IPluginManager pluginManager;

	protected final TicketIndexer indexer;

	private final Cache<TicketKey, TicketModel> ticketsCache;

	private final Map<String, List<TicketLabel>> labelsCache;

	private final Map<String, List<TicketMilestone>> milestonesCache;

	private final boolean updateDiffstats;

	private static class TicketKey {
		final String repository;
		final long ticketId;

		TicketKey(RepositoryModel repository, long ticketId) {
			this.repository = repository.name;
			this.ticketId = ticketId;
		}

		@Override
		public int hashCode() {
			return (repository + ticketId).hashCode();
		}

		@Override
		public boolean equals(Object o) {
			if (o instanceof TicketKey) {
				return o.hashCode() == hashCode();
			}
			return false;
		}

		@Override
		public String toString() {
			return repository + ":" + ticketId;
		}
	}


	/**
	 * Creates a ticket service.
	 */
	public ITicketService(
			IRuntimeManager runtimeManager,
			IPluginManager pluginManager,
			INotificationManager notificationManager,
			IUserManager userManager,
			IRepositoryManager repositoryManager) {

		this.log = LoggerFactory.getLogger(getClass());
		this.settings = runtimeManager.getSettings();
		this.runtimeManager = runtimeManager;
		this.pluginManager = pluginManager;
		this.notificationManager = notificationManager;
		this.userManager = userManager;
		this.repositoryManager = repositoryManager;

		this.indexer = new TicketIndexer(runtimeManager);

		CacheBuilder<Object, Object> cb = CacheBuilder.newBuilder();
		this.ticketsCache = cb
				.maximumSize(1000)
				.expireAfterAccess(30, TimeUnit.MINUTES)
				.build();

		this.labelsCache = new ConcurrentHashMap<String, List<TicketLabel>>();
		this.milestonesCache = new ConcurrentHashMap<String, List<TicketMilestone>>();

		this.updateDiffstats = settings.getBoolean(SETTING_UPDATE_DIFFSTATS, true);
	}

	/**
	 * Start the service.
	 * @since 1.4.0
	 */
	@Override
	public abstract ITicketService start();

	/**
	 * Stop the service.
	 * @since 1.4.0
	 */
	@Override
	public final ITicketService stop() {
		indexer.close();
		ticketsCache.invalidateAll();
		repositoryManager.closeAll();
		close();
		return this;
	}

	/**
	 * Creates a ticket notifier.  The ticket notifier is not thread-safe!
	 * @since 1.4.0
	 */
	public TicketNotifier createNotifier() {
		return new TicketNotifier(
				runtimeManager,
				notificationManager,
				userManager,
				repositoryManager,
				this);
	}

	/**
	 * Returns the ready status of the ticket service.
	 *
	 * @return true if the ticket service is ready
	 * @since 1.4.0
	 */
	public boolean isReady() {
		return true;
	}

	/**
	 * Returns true if the new patchsets can be accepted for this repository.
	 *
	 * @param repository
	 * @return true if patchsets are being accepted
	 * @since 1.4.0
	 */
	public boolean isAcceptingNewPatchsets(RepositoryModel repository) {
		return isReady()
				&& settings.getBoolean(Keys.tickets.acceptNewPatchsets, true)
				&& repository.acceptNewPatchsets
				&& isAcceptingTicketUpdates(repository);
	}

	/**
	 * Returns true if new tickets can be manually created for this repository.
	 * This is separate from accepting patchsets.
	 *
	 * @param repository
	 * @return true if tickets are being accepted
	 * @since 1.4.0
	 */
	public boolean isAcceptingNewTickets(RepositoryModel repository) {
		return isReady()
				&& settings.getBoolean(Keys.tickets.acceptNewTickets, true)
				&& repository.acceptNewTickets
				&& isAcceptingTicketUpdates(repository);
	}

	/**
	 * Returns true if ticket updates are allowed for this repository.
	 *
	 * @param repository
	 * @return true if tickets are allowed to be updated
	 * @since 1.4.0
	 */
	public boolean isAcceptingTicketUpdates(RepositoryModel repository) {
		return isReady()
				&& repository.hasCommits
				&& repository.isBare
				&& !repository.isFrozen
				&& !repository.isMirror;
	}

	/**
	 * Returns true if the repository has any tickets
	 * @param repository
	 * @return true if the repository has tickets
	 * @since 1.4.0
	 */
	public boolean hasTickets(RepositoryModel repository) {
		return indexer.hasTickets(repository);
	}

	/**
	 * Closes any open resources used by this service.
	 * @since 1.4.0
	 */
	protected abstract void close();

	/**
	 * Reset all caches in the service.
	 * @since 1.4.0
	 */
	public final synchronized void resetCaches() {
		ticketsCache.invalidateAll();
		labelsCache.clear();
		milestonesCache.clear();
		resetCachesImpl();
	}

	/**
	 * Reset all caches in the service.
	 * @since 1.4.0
	 */
	protected abstract void resetCachesImpl();

	/**
	 * Reset any caches for the repository in the service.
	 * @since 1.4.0
	 */
	public final synchronized void resetCaches(RepositoryModel repository) {
		List<TicketKey> repoKeys = new ArrayList<TicketKey>();
		for (TicketKey key : ticketsCache.asMap().keySet()) {
			if (key.repository.equals(repository.name)) {
				repoKeys.add(key);
			}
		}
		ticketsCache.invalidateAll(repoKeys);
		labelsCache.remove(repository.name);
		milestonesCache.remove(repository.name);
		resetCachesImpl(repository);
	}

	/**
	 * Reset the caches for the specified repository.
	 *
	 * @param repository
	 * @since 1.4.0
	 */
	protected abstract void resetCachesImpl(RepositoryModel repository);


	/**
	 * Returns the list of labels for the repository.
	 *
	 * @param repository
	 * @return the list of labels
	 * @since 1.4.0
	 */
	public List<TicketLabel> getLabels(RepositoryModel repository) {
		String key = repository.name;
		if (labelsCache.containsKey(key)) {
			return labelsCache.get(key);
		}
		List<TicketLabel> list = new ArrayList<TicketLabel>();
		Repository db = repositoryManager.getRepository(repository.name);
		try {
			StoredConfig config = db.getConfig();
			Set<String> names = config.getSubsections(LABEL);
			for (String name : names) {
				TicketLabel label = new TicketLabel(name);
				label.color = config.getString(LABEL, name, COLOR);
				list.add(label);
			}
			labelsCache.put(key,  Collections.unmodifiableList(list));
		} catch (Exception e) {
			log.error("invalid tickets settings for " + repository, e);
		} finally {
			db.close();
		}
		return list;
	}

	/**
	 * Returns a TicketLabel object for a given label.  If the label is not
	 * found, a ticket label object is created.
	 *
	 * @param repository
	 * @param label
	 * @return a TicketLabel
	 * @since 1.4.0
	 */
	public TicketLabel getLabel(RepositoryModel repository, String label) {
		for (TicketLabel tl : getLabels(repository)) {
			if (tl.name.equalsIgnoreCase(label)) {
				String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.labels.matches(label)).build();
				tl.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true);
				return tl;
			}
		}
		return new TicketLabel(label);
	}

	/**
	 * Creates a label.
	 *
	 * @param repository
	 * @param milestone
	 * @param createdBy
	 * @return the label
	 * @since 1.4.0
	 */
	public synchronized TicketLabel createLabel(RepositoryModel repository, String label, String createdBy) {
		TicketLabel lb = new TicketMilestone(label);
		Repository db = null;
		try {
			db = repositoryManager.getRepository(repository.name);
			StoredConfig config = db.getConfig();
			config.setString(LABEL, label, COLOR, lb.color);
			config.save();
		} catch (IOException e) {
			log.error("failed to create label " + label + " in " + repository, e);
		} finally {
			if (db != null) {
				db.close();
			}
		}
		return lb;
	}

	/**
	 * Updates a label.
	 *
	 * @param repository
	 * @param label
	 * @param createdBy
	 * @return true if the update was successful
	 * @since 1.4.0
	 */
	public synchronized boolean updateLabel(RepositoryModel repository, TicketLabel label, String createdBy) {
		Repository db = null;
		try {
			db = repositoryManager.getRepository(repository.name);
			StoredConfig config = db.getConfig();
			config.setString(LABEL, label.name, COLOR, label.color);
			config.save();

			return true;
		} catch (IOException e) {
			log.error("failed to update label " + label + " in " + repository, e);
		} finally {
			if (db != null) {
				db.close();
			}
		}
		return false;
	}

	/**
	 * Renames a label.
	 *
	 * @param repository
	 * @param oldName
	 * @param newName
	 * @param createdBy
	 * @return true if the rename was successful
	 * @since 1.4.0
	 */
	public synchronized boolean renameLabel(RepositoryModel repository, String oldName, String newName, String createdBy) {
		if (StringUtils.isEmpty(newName)) {
			throw new IllegalArgumentException("new label can not be empty!");
		}
		Repository db = null;
		try {
			db = repositoryManager.getRepository(repository.name);
			TicketLabel label = getLabel(repository, oldName);
			StoredConfig config = db.getConfig();
			config.unsetSection(LABEL, oldName);
			config.setString(LABEL, newName, COLOR, label.color);
			config.save();

			for (QueryResult qr : label.tickets) {
				Change change = new Change(createdBy);
				change.unlabel(oldName);
				change.label(newName);
				updateTicket(repository, qr.number, change);
			}

			return true;
		} catch (IOException e) {
			log.error("failed to rename label " + oldName + " in " + repository, e);
		} finally {
			if (db != null) {
				db.close();
			}
		}
		return false;
	}

	/**
	 * Deletes a label.
	 *
	 * @param repository
	 * @param label
	 * @param createdBy
	 * @return true if the delete was successful
	 * @since 1.4.0
	 */
	public synchronized boolean deleteLabel(RepositoryModel repository, String label, String createdBy) {
		if (StringUtils.isEmpty(label)) {
			throw new IllegalArgumentException("label can not be empty!");
		}
		Repository db = null;
		try {
			db = repositoryManager.getRepository(repository.name);
			StoredConfig config = db.getConfig();
			config.unsetSection(LABEL, label);
			config.save();

			return true;
		} catch (IOException e) {
			log.error("failed to delete label " + label + " in " + repository, e);
		} finally {
			if (db != null) {
				db.close();
			}
		}
		return false;
	}

	/**
	 * Returns the list of milestones for the repository.
	 *
	 * @param repository
	 * @return the list of milestones
	 * @since 1.4.0
	 */
	public List<TicketMilestone> getMilestones(RepositoryModel repository) {
		String key = repository.name;
		if (milestonesCache.containsKey(key)) {
			return milestonesCache.get(key);
		}
		List<TicketMilestone> list = new ArrayList<TicketMilestone>();
		Repository db = repositoryManager.getRepository(repository.name);
		try {
			StoredConfig config = db.getConfig();
			Set<String> names = config.getSubsections(MILESTONE);
			for (String name : names) {
				TicketMilestone milestone = new TicketMilestone(name);
				milestone.status = Status.fromObject(config.getString(MILESTONE, name, STATUS), milestone.status);
				milestone.color = config.getString(MILESTONE, name, COLOR);
				String due = config.getString(MILESTONE, name, DUE);
				if (!StringUtils.isEmpty(due)) {
					try {
						milestone.due = new SimpleDateFormat(DUE_DATE_PATTERN).parse(due);
					} catch (ParseException e) {
						log.error("failed to parse {} milestone {} due date \"{}\"",
								new Object [] { repository, name, due });
					}
				}
				list.add(milestone);
			}
			milestonesCache.put(key, Collections.unmodifiableList(list));
		} catch (Exception e) {
			log.error("invalid tickets settings for " + repository, e);
		} finally {
			db.close();
		}
		return list;
	}

	/**
	 * Returns the list of milestones for the repository that match the status.
	 *
	 * @param repository
	 * @param status
	 * @return the list of milestones
	 * @since 1.4.0
	 */
	public List<TicketMilestone> getMilestones(RepositoryModel repository, Status status) {
		List<TicketMilestone> matches = new ArrayList<TicketMilestone>();
		for (TicketMilestone milestone : getMilestones(repository)) {
			if (status == milestone.status) {
				matches.add(milestone);
			}
		}
		return matches;
	}

	/**
	 * Returns the specified milestone or null if the milestone does not exist.
	 *
	 * @param repository
	 * @param milestone
	 * @return the milestone or null if it does not exist
	 * @since 1.4.0
	 */
	public TicketMilestone getMilestone(RepositoryModel repository, String milestone) {
		for (TicketMilestone ms : getMilestones(repository)) {
			if (ms.name.equalsIgnoreCase(milestone)) {
				TicketMilestone tm = DeepCopier.copy(ms);
				String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.milestone.matches(milestone)).build();
				tm.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true);
				return tm;
			}
		}
		return null;
	}

	/**
	 * Creates a milestone.
	 *
	 * @param repository
	 * @param milestone
	 * @param createdBy
	 * @return the milestone
	 * @since 1.4.0
	 */
	public synchronized TicketMilestone createMilestone(RepositoryModel repository, String milestone, String createdBy) {
		TicketMilestone ms = new TicketMilestone(milestone);
		Repository db = null;
		try {
			db = repositoryManager.getRepository(repository.name);
			StoredConfig config = db.getConfig();
			config.setString(MILESTONE, milestone, STATUS, ms.status.name());
			config.setString(MILESTONE, milestone, COLOR, ms.color);
			config.save();

			milestonesCache.remove(repository.name);
		} catch (IOException e) {
			log.error("failed to create milestone " + milestone + " in " + repository, e);
		} finally {
			if (db != null) {
				db.close();
			}
		}
		return ms;
	}

	/**
	 * Updates a milestone.
	 *
	 * @param repository
	 * @param milestone
	 * @param createdBy
	 * @return true if successful
	 * @since 1.4.0
	 */
	public synchronized boolean updateMilestone(RepositoryModel repository, TicketMilestone milestone, String createdBy) {
		Repository db = null;
		try {
			db = repositoryManager.getRepository(repository.name);
			StoredConfig config = db.getConfig();
			config.setString(MILESTONE, milestone.name, STATUS, milestone.status.name());
			config.setString(MILESTONE, milestone.name, COLOR, milestone.color);
			if (milestone.due != null) {
				config.setString(MILESTONE, milestone.name, DUE,
						new SimpleDateFormat(DUE_DATE_PATTERN).format(milestone.due));
			}
			config.save();

			milestonesCache.remove(repository.name);
			return true;
		} catch (IOException e) {
			log.error("failed to update milestone " + milestone + " in " + repository, e);
		} finally {
			if (db != null) {
				db.close();
			}
		}
		return false;
	}

	/**
	 * Renames a milestone.
	 *
	 * @param repository
	 * @param oldName
	 * @param newName
	 * @param createdBy
	 * @return true if successful
	 * @since 1.4.0
	 */
	public synchronized boolean renameMilestone(RepositoryModel repository, String oldName, String newName, String createdBy) {
		return renameMilestone(repository, oldName, newName, createdBy, true);
	}

	/**
	 * Renames a milestone.
	 *
	 * @param repository
	 * @param oldName
	 * @param newName
	 * @param createdBy
	 * @param notifyOpenTickets
	 * @return true if successful
	 * @since 1.6.0
	 */
	public synchronized boolean renameMilestone(RepositoryModel repository, String oldName,
			String newName, String createdBy, boolean notifyOpenTickets) {
		if (StringUtils.isEmpty(newName)) {
			throw new IllegalArgumentException("new milestone can not be empty!");
		}
		Repository db = null;
		try {
			db = repositoryManager.getRepository(repository.name);
			TicketMilestone tm = getMilestone(repository, oldName);
			if (tm == null) {
				return false;
			}
			StoredConfig config = db.getConfig();
			config.unsetSection(MILESTONE, oldName);
			config.setString(MILESTONE, newName, STATUS, tm.status.name());
			config.setString(MILESTONE, newName, COLOR, tm.color);
			if (tm.due != null) {
				config.setString(MILESTONE, newName, DUE,
						new SimpleDateFormat(DUE_DATE_PATTERN).format(tm.due));
			}
			config.save();

			milestonesCache.remove(repository.name);

			TicketNotifier notifier = createNotifier();
			for (QueryResult qr : tm.tickets) {
				Change change = new Change(createdBy);
				change.setField(Field.milestone, newName);
				TicketModel ticket = updateTicket(repository, qr.number, change);
				if (notifyOpenTickets && ticket.isOpen()) {
					notifier.queueMailing(ticket);
				}
			}
			if (notifyOpenTickets) {
				notifier.sendAll();
			}

			return true;
		} catch (IOException e) {
			log.error("failed to rename milestone " + oldName + " in " + repository, e);
		} finally {
			if (db != null) {
				db.close();
			}
		}
		return false;
	}

	/**
	 * Deletes a milestone.
	 *
	 * @param repository
	 * @param milestone
	 * @param createdBy
	 * @return true if successful
	 * @since 1.4.0
	 */
	public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone, String createdBy) {
		return deleteMilestone(repository, milestone, createdBy, true);
	}

	/**
	 * Deletes a milestone.
	 *
	 * @param repository
	 * @param milestone
	 * @param createdBy
	 * @param notifyOpenTickets
	 * @return true if successful
	 * @since 1.6.0
	 */
	public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone,
			String createdBy, boolean notifyOpenTickets) {
		if (StringUtils.isEmpty(milestone)) {
			throw new IllegalArgumentException("milestone can not be empty!");
		}
		Repository db = null;
		try {
			TicketMilestone tm = getMilestone(repository, milestone);
			if (tm == null) {
				return false;
			}
			db = repositoryManager.getRepository(repository.name);
			StoredConfig config = db.getConfig();
			config.unsetSection(MILESTONE, milestone);
			config.save();

			milestonesCache.remove(repository.name);

			TicketNotifier notifier = createNotifier();
			for (QueryResult qr : tm.tickets) {
				Change change = new Change(createdBy);
				change.setField(Field.milestone, "");
				TicketModel ticket = updateTicket(repository, qr.number, change);
				if (notifyOpenTickets && ticket.isOpen()) {
					notifier.queueMailing(ticket);
				}
			}
			if (notifyOpenTickets) {
				notifier.sendAll();
			}
			return true;
		} catch (IOException e) {
			log.error("failed to delete milestone " + milestone + " in " + repository, e);
		} finally {
			if (db != null) {
				db.close();
			}
		}
		return false;
	}

	/**
	 * Returns the set of assigned ticket ids in the repository.
	 *
	 * @param repository
	 * @return a set of assigned ticket ids in the repository
	 * @since 1.6.0
	 */
	public abstract Set<Long> getIds(RepositoryModel repository);

	/**
	 * Assigns a new ticket id.
	 *
	 * @param repository
	 * @return a new ticket id
	 * @since 1.4.0
	 */
	public abstract long assignNewId(RepositoryModel repository);

	/**
	 * Ensures that we have a ticket for this ticket id.
	 *
	 * @param repository
	 * @param ticketId
	 * @return true if the ticket exists
	 * @since 1.4.0
	 */
	public abstract boolean hasTicket(RepositoryModel repository, long ticketId);

	/**
	 * Returns all tickets.  This is not a Lucene search!
	 *
	 * @param repository
	 * @return all tickets
	 * @since 1.4.0
	 */
	public List<TicketModel> getTickets(RepositoryModel repository) {
		return getTickets(repository, null);
	}

	/**
	 * Returns all tickets that satisfy the filter. Retrieving tickets from the
	 * service requires deserializing all journals and building ticket models.
	 * This is an  expensive process and not recommended. Instead, the queryFor
	 * method should be used which executes against the Lucene index.
	 *
	 * @param repository
	 * @param filter
	 *            optional issue filter to only return matching results
	 * @return a list of tickets
	 * @since 1.4.0
	 */
	public abstract List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter);

	/**
	 * Retrieves the ticket.
	 *
	 * @param repository
	 * @param ticketId
	 * @return a ticket, if it exists, otherwise null
	 * @since 1.4.0
	 */
	public final TicketModel getTicket(RepositoryModel repository, long ticketId) {
		TicketKey key = new TicketKey(repository, ticketId);
		TicketModel ticket = ticketsCache.getIfPresent(key);

		// if ticket not cached
		if (ticket == null) {
			//load ticket
			ticket = getTicketImpl(repository, ticketId);
			// if ticket exists
			if (ticket != null) {
				if (ticket.hasPatchsets() && updateDiffstats) {
					Repository r = repositoryManager.getRepository(repository.name);
					try {
						Patchset patchset = ticket.getCurrentPatchset();
						DiffStat diffStat = DiffUtils.getDiffStat(r, patchset.base, patchset.tip);
						// diffstat could be null if we have ticket data without the
						// commit objects.  e.g. ticket replication without repo
						// mirroring
						if (diffStat != null) {
							ticket.insertions = diffStat.getInsertions();
							ticket.deletions = diffStat.getDeletions();
						}
					} finally {
						r.close();
					}
				}
				//cache ticket
				ticketsCache.put(key, ticket);
			}
		}
		return ticket;
	}

	/**
	 * Retrieves the ticket.
	 *
	 * @param repository
	 * @param ticketId
	 * @return a ticket, if it exists, otherwise null
	 * @since 1.4.0
	 */
	protected abstract TicketModel getTicketImpl(RepositoryModel repository, long ticketId);


	/**
	 * Returns the journal used to build a ticket.
	 *
	 * @param repository
	 * @param ticketId
	 * @return the journal for the ticket, if it exists, otherwise null
	 * @since 1.6.0
	 */
	public final List<Change> getJournal(RepositoryModel repository, long ticketId) {
		if (hasTicket(repository, ticketId)) {
			List<Change> journal = getJournalImpl(repository, ticketId);
			return journal;
		}
		return null;
	}

	/**
	 * Retrieves the ticket journal.
	 *
	 * @param repository
	 * @param ticketId
	 * @return a ticket, if it exists, otherwise null
	 * @since 1.6.0
	 */
	protected abstract List<Change> getJournalImpl(RepositoryModel repository, long ticketId);

	/**
	 * Get the ticket url
	 *
	 * @param ticket
	 * @return the ticket url
	 * @since 1.4.0
	 */
	public String getTicketUrl(TicketModel ticket) {
		final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");
		final String hrefPattern = "{0}/tickets?r={1}&h={2,number,0}";
		return MessageFormat.format(hrefPattern, canonicalUrl, ticket.repository, ticket.number);
	}

	/**
	 * Get the compare url
	 *
	 * @param base
	 * @param tip
	 * @return the compare url
	 * @since 1.4.0
	 */
	public String getCompareUrl(TicketModel ticket, String base, String tip) {
		final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");
		final String hrefPattern = "{0}/compare?r={1}&h={2}..{3}";
		return MessageFormat.format(hrefPattern, canonicalUrl, ticket.repository, base, tip);
	}

	/**
	 * Returns true if attachments are supported.
	 *
	 * @return true if attachments are supported
	 * @since 1.4.0
	 */
	public abstract boolean supportsAttachments();

	/**
	 * Retrieves the specified attachment from a ticket.
	 *
	 * @param repository
	 * @param ticketId
	 * @param filename
	 * @return an attachment, if found, null otherwise
	 * @since 1.4.0
	 */
	public abstract Attachment getAttachment(RepositoryModel repository, long ticketId, String filename);

	/**
	 * Creates a ticket.  Your change must include a repository, author & title,
	 * at a minimum. If your change does not have those minimum requirements a
	 * RuntimeException will be thrown.
	 *
	 * @param repository
	 * @param change
	 * @return true if successful
	 * @since 1.4.0
	 */
	public TicketModel createTicket(RepositoryModel repository, Change change) {
		return createTicket(repository, 0L, change);
	}

	/**
	 * Creates a ticket.  Your change must include a repository, author & title,
	 * at a minimum. If your change does not have those minimum requirements a
	 * RuntimeException will be thrown.
	 *
	 * @param repository
	 * @param ticketId (if <=0 the ticket id will be assigned)
	 * @param change
	 * @return true if successful
	 * @since 1.4.0
	 */
	public TicketModel createTicket(RepositoryModel repository, long ticketId, Change change) {

		if (repository == null) {
			throw new RuntimeException("Must specify a repository!");
		}
		if (StringUtils.isEmpty(change.author)) {
			throw new RuntimeException("Must specify a change author!");
		}
		if (!change.hasField(Field.title)) {
			throw new RuntimeException("Must specify a title!");
		}

		change.watch(change.author);

		if (ticketId <= 0L) {
			ticketId = assignNewId(repository);
		}

		change.setField(Field.status, Status.New);

		boolean success = commitChangeImpl(repository, ticketId, change);
		if (success) {
			TicketModel ticket = getTicket(repository, ticketId);
			indexer.index(ticket);

			// call the ticket hooks
			if (pluginManager != null) {
				for (TicketHook hook : pluginManager.getExtensions(TicketHook.class)) {
					try {
						hook.onNewTicket(ticket);
					} catch (Exception e) {
						log.error("Failed to execute extension", e);
					}
				}
			}
			return ticket;
		}
		return null;
	}

	/**
	 * Updates a ticket and promotes pending links into references.
	 *
	 * @param repository
	 * @param ticketId, or 0 to action pending links in general
	 * @param change
	 * @return the ticket model if successful, null if failure or using 0 ticketId
	 * @since 1.4.0
	 */
	public final TicketModel updateTicket(RepositoryModel repository, long ticketId, Change change) {
		if (change == null) {
			throw new RuntimeException("change can not be null!");
		}

		if (StringUtils.isEmpty(change.author)) {
			throw new RuntimeException("must specify a change author!");
		}

		boolean success = true;
		TicketModel ticket = null;
		
		if (ticketId > 0) {
			TicketKey key = new TicketKey(repository, ticketId);
			ticketsCache.invalidate(key);
	
			success = commitChangeImpl(repository, ticketId, change);
			
			if (success) {
				ticket = getTicket(repository, ticketId);
				ticketsCache.put(key, ticket);
				indexer.index(ticket);
	
				// call the ticket hooks
				if (pluginManager != null) {
					for (TicketHook hook : pluginManager.getExtensions(TicketHook.class)) {
						try {
							hook.onUpdateTicket(ticket, change);
						} catch (Exception e) {
							log.error("Failed to execute extension", e);
						}
					}
				}
			}
		}
		
		if (success) {
			//Now that the ticket has been successfully persisted add references to this ticket from linked tickets
			if (change.hasPendingLinks()) {
				for (TicketLink link : change.pendingLinks) {
					TicketModel linkedTicket = getTicket(repository, link.targetTicketId);
					Change dstChange = null;
					
					//Ignore if not available or self reference 
					if (linkedTicket != null && link.targetTicketId != ticketId) {
						dstChange = new Change(change.author, change.date);
						
						switch (link.action) {
							case Comment: {
								if (ticketId == 0) {
									throw new RuntimeException("must specify a ticket when linking a comment!");
								}
								dstChange.referenceTicket(ticketId, change.comment.id);
							} break;
							
							case Commit: {
								dstChange.referenceCommit(link.hash);
							} break;
							
							default: {
								throw new RuntimeException(
										String.format("must add persist logic for link of type %s", link.action));
							}
						}
					}
					
					if (dstChange != null) {
						//If not deleted then remain null in journal
						if (link.isDelete) {
							dstChange.reference.deleted = true;
						}

						if (updateTicket(repository, link.targetTicketId, dstChange) != null) {
							link.success = true;
						}
					}
				}
			}
		}
		
		return ticket;
	}

	/**
	 * Deletes all tickets in every repository.
	 *
	 * @return true if successful
	 * @since 1.4.0
	 */
	public boolean deleteAll() {
		List<String> repositories = repositoryManager.getRepositoryList();
		BitSet bitset = new BitSet(repositories.size());
		for (int i = 0; i < repositories.size(); i++) {
			String name = repositories.get(i);
			RepositoryModel repository = repositoryManager.getRepositoryModel(name);
			boolean success = deleteAll(repository);
			bitset.set(i, success);
		}
		boolean success = bitset.cardinality() == repositories.size();
		if (success) {
			indexer.deleteAll();
			resetCaches();
		}
		return success;
	}

	/**
	 * Deletes all tickets in the specified repository.
	 * @param repository
	 * @return true if succesful
	 * @since 1.4.0
	 */
	public boolean deleteAll(RepositoryModel repository) {
		boolean success = deleteAllImpl(repository);
		if (success) {
			log.info("Deleted all tickets for {}", repository.name);
			resetCaches(repository);
			indexer.deleteAll(repository);
		}
		return success;
	}

	/**
	 * Delete all tickets for the specified repository.
	 * @param repository
	 * @return true if successful
	 * @since 1.4.0
	 */
	protected abstract boolean deleteAllImpl(RepositoryModel repository);

	/**
	 * Handles repository renames.
	 *
	 * @param oldRepositoryName
	 * @param newRepositoryName
	 * @return true if successful
	 * @since 1.4.0
	 */
	public boolean rename(RepositoryModel oldRepository, RepositoryModel newRepository) {
		if (renameImpl(oldRepository, newRepository)) {
			resetCaches(oldRepository);
			indexer.deleteAll(oldRepository);
			reindex(newRepository);
			return true;
		}
		return false;
	}

	/**
	 * Renames a repository.
	 *
	 * @param oldRepository
	 * @param newRepository
	 * @return true if successful
	 * @since 1.4.0
	 */
	protected abstract boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository);

	/**
	 * Deletes a ticket.
	 *
	 * @param repository
	 * @param ticketId
	 * @param deletedBy
	 * @return true if successful
	 * @since 1.4.0
	 */
	public boolean deleteTicket(RepositoryModel repository, long ticketId, String deletedBy) {
		TicketModel ticket = getTicket(repository, ticketId);
		boolean success = deleteTicketImpl(repository, ticket, deletedBy);
		if (success) {
			log.info(MessageFormat.format("Deleted {0} ticket #{1,number,0}: {2}",
					repository.name, ticketId, ticket.title));
			ticketsCache.invalidate(new TicketKey(repository, ticketId));
			indexer.delete(ticket);
			return true;
		}
		return false;
	}

	/**
	 * Deletes a ticket.
	 *
	 * @param repository
	 * @param ticket
	 * @param deletedBy
	 * @return true if successful
	 * @since 1.4.0
	 */
	protected abstract boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy);


	/**
	 * Updates the text of an ticket comment.
	 *
	 * @param ticket
	 * @param commentId
	 *            the id of the comment to revise
	 * @param updatedBy
	 *            the author of the updated comment
	 * @param comment
	 *            the revised comment
	 * @return the revised ticket if the change was successful
	 * @since 1.4.0
	 */
	public final TicketModel updateComment(TicketModel ticket, String commentId,
			String updatedBy, String comment) {
		Change revision = new Change(updatedBy);
		revision.comment(comment);
		revision.comment.id = commentId;
		RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository);
		TicketModel revisedTicket = updateTicket(repository, ticket.number, revision);
		return revisedTicket;
	}

	/**
	 * Deletes a comment from a ticket.
	 *
	 * @param ticket
	 * @param commentId
	 *            the id of the comment to delete
	 * @param deletedBy
	 * 			the user deleting the comment
	 * @return the revised ticket if the deletion was successful
	 * @since 1.4.0
	 */
	public final TicketModel deleteComment(TicketModel ticket, String commentId, String deletedBy) {
		Change deletion = new Change(deletedBy);
		deletion.comment("");
		deletion.comment.id = commentId;
		deletion.comment.deleted = true;
		RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository);
		TicketModel revisedTicket = updateTicket(repository, ticket.number, deletion);
		return revisedTicket;
	}
	
	/**
	 * Deletes a patchset from a ticket.
	 *
	 * @param ticket
	 * @param patchset
	 *            the patchset to delete (should be the highest revision)
	 * @param userName
	 * 			the user deleting the commit
	 * @return the revised ticket if the deletion was successful
	 * @since 1.8.0
	 */
	public final TicketModel deletePatchset(TicketModel ticket, Patchset patchset, String userName) {
		Change deletion = new Change(userName);
		deletion.patchset = new Patchset();
		deletion.patchset.number = patchset.number;
		deletion.patchset.rev = patchset.rev;
		deletion.patchset.type = PatchsetType.Delete;
		//Find and delete references to tickets by the removed commits
		List<TicketLink> patchsetTicketLinks = JGitUtils.identifyTicketsBetweenCommits(
				repositoryManager.getRepository(ticket.repository),
				settings, patchset.base, patchset.tip);
		
		for (TicketLink link : patchsetTicketLinks) {
			link.isDelete = true;
		}
		deletion.pendingLinks = patchsetTicketLinks;
		
		RepositoryModel repositoryModel = repositoryManager.getRepositoryModel(ticket.repository);
		TicketModel revisedTicket = updateTicket(repositoryModel, ticket.number, deletion);
		
		return revisedTicket;
	} 

	/**
	 * Commit a ticket change to the repository.
	 *
	 * @param repository
	 * @param ticketId
	 * @param change
	 * @return true, if the change was committed
	 * @since 1.4.0
	 */
	protected abstract boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change);


	/**
	 * Searches for the specified text.  This will use the indexer, if available,
	 * or will fall back to brute-force retrieval of all tickets and string
	 * matching.
	 *
	 * @param repository
	 * @param text
	 * @param page
	 * @param pageSize
	 * @return a list of matching tickets
	 * @since 1.4.0
	 */
	public List<QueryResult> searchFor(RepositoryModel repository, String text, int page, int pageSize) {
		return indexer.searchFor(repository, text, page, pageSize);
	}

	/**
	 * Queries the index for the matching tickets.
	 *
	 * @param query
	 * @param page
	 * @param pageSize
	 * @param sortBy
	 * @param descending
	 * @return a list of matching tickets or an empty list
	 * @since 1.4.0
	 */
	public List<QueryResult> queryFor(String query, int page, int pageSize, String sortBy, boolean descending) {
		return indexer.queryFor(query, page, pageSize, sortBy, descending);
	}

	/**
	 * Destroys an existing index and reindexes all tickets.
	 * This operation may be expensive and time-consuming.
	 * @since 1.4.0
	 */
	public void reindex() {
		long start = System.nanoTime();
		indexer.deleteAll();
		for (String name : repositoryManager.getRepositoryList()) {
			RepositoryModel repository = repositoryManager.getRepositoryModel(name);
			try {
			List<TicketModel> tickets = getTickets(repository);
			if (!tickets.isEmpty()) {
				log.info("reindexing {} tickets from {} ...", tickets.size(), repository);
				indexer.index(tickets);
				System.gc();
			}
			} catch (Exception e) {
				log.error("failed to reindex {}", repository.name);
				log.error(null, e);
			}
		}
		long end = System.nanoTime();
		long secs = TimeUnit.NANOSECONDS.toMillis(end - start);
		log.info("reindexing completed in {} msecs.", secs);
	}

	/**
	 * Destroys any existing index and reindexes all tickets.
	 * This operation may be expensive and time-consuming.
	 * @since 1.4.0
	 */
	public void reindex(RepositoryModel repository) {
		long start = System.nanoTime();
		List<TicketModel> tickets = getTickets(repository);
		indexer.index(tickets);
		log.info("reindexing {} tickets from {} ...", tickets.size(), repository);
		long end = System.nanoTime();
		long secs = TimeUnit.NANOSECONDS.toMillis(end - start);
		log.info("reindexing completed in {} msecs.", secs);
		resetCaches(repository);
	}

	/**
	 * Synchronously executes the runnable. This is used for special processing
	 * of ticket updates, namely merging from the web ui.
	 *
	 * @param runnable
	 * @since 1.4.0
	 */
	public synchronized void exec(Runnable runnable) {
		runnable.run();
	}
}
