/*
 * 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.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

import org.apache.commons.pool2.impl.GenericObjectPoolConfig;

import redis.clients.jedis.Client;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.Protocol;
import redis.clients.jedis.Transaction;
import redis.clients.jedis.exceptions.JedisException;

import com.gitblit.Keys;
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.utils.ArrayUtils;
import com.gitblit.utils.StringUtils;
import com.google.inject.Inject;
import com.google.inject.Singleton;

/**
 * Implementation of a ticket service based on a Redis key-value store.  All
 * tickets are persisted in the Redis store so it must be configured for
 * durability otherwise tickets are lost on a flush or restart.  Tickets are
 * indexed with Lucene and all queries are executed against the Lucene index.
 *
 * @author James Moger
 *
 */
@Singleton
public class RedisTicketService extends ITicketService {

	private final JedisPool pool;

	private enum KeyType {
		journal, ticket, counter
	}

	@Inject
	public RedisTicketService(
			IRuntimeManager runtimeManager,
			IPluginManager pluginManager,
			INotificationManager notificationManager,
			IUserManager userManager,
			IRepositoryManager repositoryManager) {

		super(runtimeManager,
				pluginManager,
				notificationManager,
				userManager,
				repositoryManager);

		String redisUrl = settings.getString(Keys.tickets.redis.url, "");
		this.pool = createPool(redisUrl);
	}

	@Override
	public RedisTicketService start() {
		log.info("{} started", getClass().getSimpleName());
		if (!isReady()) {
			log.warn("{} is not ready!", getClass().getSimpleName());
		}
		return this;
	}

	@Override
	protected void resetCachesImpl() {
	}

	@Override
	protected void resetCachesImpl(RepositoryModel repository) {
	}

	@Override
	protected void close() {
		pool.destroy();
	}

	@Override
	public boolean isReady() {
		return pool != null;
	}

	/**
	 * Constructs a key for use with a key-value data store.
	 *
	 * @param key
	 * @param repository
	 * @param id
	 * @return a key
	 */
	private String key(RepositoryModel repository, KeyType key, String id) {
		StringBuilder sb = new StringBuilder();
		sb.append(repository.name).append(':');
		sb.append(key.name());
		if (!StringUtils.isEmpty(id)) {
			sb.append(':');
			sb.append(id);
		}
		return sb.toString();
	}

	/**
	 * Constructs a key for use with a key-value data store.
	 *
	 * @param key
	 * @param repository
	 * @param id
	 * @return a key
	 */
	private String key(RepositoryModel repository, KeyType key, long id) {
		return key(repository, key, "" + id);
	}

	private boolean isNull(String value) {
		return value == null || "nil".equals(value);
	}

	private String getUrl() {
		Jedis jedis = pool.getResource();
		try {
			if (jedis != null) {
				Client client = jedis.getClient();
				return client.getHost() + ":" + client.getPort() + "/" + client.getDB();
			}
		} catch (JedisException e) {
			pool.returnBrokenResource(jedis);
			jedis = null;
		} finally {
			if (jedis != null) {
				pool.returnResource(jedis);
			}
		}
		return null;
	}

	/**
	 * Ensures that we have a ticket for this ticket id.
	 *
	 * @param repository
	 * @param ticketId
	 * @return true if the ticket exists
	 */
	@Override
	public boolean hasTicket(RepositoryModel repository, long ticketId) {
		if (ticketId <= 0L) {
			return false;
		}
		Jedis jedis = pool.getResource();
		if (jedis == null) {
			return false;
		}
		try {
			Boolean exists = jedis.exists(key(repository, KeyType.journal, ticketId));
			return exists != null && exists;
		} catch (JedisException e) {
			log.error("failed to check hasTicket from Redis @ " + getUrl(), e);
			pool.returnBrokenResource(jedis);
			jedis = null;
		} finally {
			if (jedis != null) {
				pool.returnResource(jedis);
			}
		}
		return false;
	}

	@Override
	public Set<Long> getIds(RepositoryModel repository) {
		Set<Long> ids = new TreeSet<Long>();
		Jedis jedis = pool.getResource();
		try {// account for migrated tickets
			Set<String> keys = jedis.keys(key(repository, KeyType.journal, "*"));
			for (String tkey : keys) {
				// {repo}:journal:{id}
				String id = tkey.split(":")[2];
				long ticketId = Long.parseLong(id);
				ids.add(ticketId);
			}
		} catch (JedisException e) {
			log.error("failed to assign new ticket id in Redis @ " + getUrl(), e);
			pool.returnBrokenResource(jedis);
			jedis = null;
		} finally {
			if (jedis != null) {
				pool.returnResource(jedis);
			}
		}
		return ids;
	}

	/**
	 * Assigns a new ticket id.
	 *
	 * @param repository
	 * @return a new long ticket id
	 */
	@Override
	public synchronized long assignNewId(RepositoryModel repository) {
		Jedis jedis = pool.getResource();
		try {
			String key = key(repository, KeyType.counter, null);
			String val = jedis.get(key);
			if (isNull(val)) {
				long lastId = 0;
				Set<Long> ids = getIds(repository);
				for (long id : ids) {
					if (id > lastId) {
						lastId = id;
					}
				}
				jedis.set(key, "" + lastId);
			}
			long ticketNumber = jedis.incr(key);
			return ticketNumber;
		} catch (JedisException e) {
			log.error("failed to assign new ticket id in Redis @ " + getUrl(), e);
			pool.returnBrokenResource(jedis);
			jedis = null;
		} finally {
			if (jedis != null) {
				pool.returnResource(jedis);
			}
		}
		return 0L;
	}

	/**
	 * Returns all the tickets in the repository. Querying tickets from the
	 * repository requires deserializing all tickets. This is an  expensive
	 * process and not recommended. Tickets should be indexed by Lucene and
	 * queries should be executed against that index.
	 *
	 * @param repository
	 * @param filter
	 *            optional filter to only return matching results
	 * @return a list of tickets
	 */
	@Override
	public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) {
		Jedis jedis = pool.getResource();
		List<TicketModel> list = new ArrayList<TicketModel>();
		if (jedis == null) {
			return list;
		}
		try {
			// Deserialize each journal, build the ticket, and optionally filter
			Set<String> keys = jedis.keys(key(repository, KeyType.journal, "*"));
			for (String key : keys) {
				// {repo}:journal:{id}
				String id = key.split(":")[2];
				long ticketId = Long.parseLong(id);
				List<Change> changes = getJournal(jedis, repository, ticketId);
				if (ArrayUtils.isEmpty(changes)) {
					log.warn("Empty journal for {}:{}", repository, ticketId);
					continue;
				}
				TicketModel ticket = TicketModel.buildTicket(changes);
				ticket.project = repository.projectPath;
				ticket.repository = repository.name;
				ticket.number = ticketId;

				// add the ticket, conditionally, to the list
				if (filter == null) {
					list.add(ticket);
				} else {
					if (filter.accept(ticket)) {
						list.add(ticket);
					}
				}
			}

			// sort the tickets by creation
			Collections.sort(list);
		} catch (JedisException e) {
			log.error("failed to retrieve tickets from Redis @ " + getUrl(), e);
			pool.returnBrokenResource(jedis);
			jedis = null;
		} finally {
			if (jedis != null) {
				pool.returnResource(jedis);
			}
		}
		return list;
	}

	/**
	 * Retrieves the ticket from the repository.
	 *
	 * @param repository
	 * @param ticketId
	 * @return a ticket, if it exists, otherwise null
	 */
	@Override
	protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) {
		Jedis jedis = pool.getResource();
		if (jedis == null) {
			return null;
		}

		try {
			List<Change> changes = getJournal(jedis, repository, ticketId);
			if (ArrayUtils.isEmpty(changes)) {
				log.warn("Empty journal for {}:{}", repository, ticketId);
				return null;
			}
			TicketModel ticket = TicketModel.buildTicket(changes);
			ticket.project = repository.projectPath;
			ticket.repository = repository.name;
			ticket.number = ticketId;
			log.debug("rebuilt ticket {} from Redis @ {}", ticketId, getUrl());
			return ticket;
		} catch (JedisException e) {
			log.error("failed to retrieve ticket from Redis @ " + getUrl(), e);
			pool.returnBrokenResource(jedis);
			jedis = null;
		} finally {
			if (jedis != null) {
				pool.returnResource(jedis);
			}
		}
		return null;
	}

	/**
	 * Retrieves the journal for the ticket.
	 *
	 * @param repository
	 * @param ticketId
	 * @return a journal, if it exists, otherwise null
	 */
	@Override
	protected List<Change> getJournalImpl(RepositoryModel repository, long ticketId) {
		Jedis jedis = pool.getResource();
		if (jedis == null) {
			return null;
		}

		try {
			List<Change> changes = getJournal(jedis, repository, ticketId);
			if (ArrayUtils.isEmpty(changes)) {
				log.warn("Empty journal for {}:{}", repository, ticketId);
				return null;
			}
			return changes;
		} catch (JedisException e) {
			log.error("failed to retrieve journal from Redis @ " + getUrl(), e);
			pool.returnBrokenResource(jedis);
			jedis = null;
		} finally {
			if (jedis != null) {
				pool.returnResource(jedis);
			}
		}
		return null;
	}

	/**
	 * Returns the journal for the specified ticket.
	 *
	 * @param repository
	 * @param ticketId
	 * @return a list of changes
	 */
	private List<Change> getJournal(Jedis jedis, RepositoryModel repository, long ticketId) throws JedisException {
		if (ticketId <= 0L) {
			return new ArrayList<Change>();
		}
		List<String> entries = jedis.lrange(key(repository, KeyType.journal, ticketId), 0, -1);
		if (entries.size() > 0) {
			// build a json array from the individual entries
			StringBuilder sb = new StringBuilder();
			sb.append("[");
			for (String entry : entries) {
				sb.append(entry).append(',');
			}
			sb.setLength(sb.length() - 1);
			sb.append(']');
			String journal = sb.toString();

			return TicketSerializer.deserializeJournal(journal);
		}
		return new ArrayList<Change>();
	}

	@Override
	public boolean supportsAttachments() {
		return false;
	}

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

	/**
	 * Deletes a ticket.
	 *
	 * @param ticket
	 * @return true if successful
	 */
	@Override
	protected boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) {
		boolean success = false;
		if (ticket == null) {
			throw new RuntimeException("must specify a ticket!");
		}

		Jedis jedis = pool.getResource();
		if (jedis == null) {
			return false;
		}

		try {
			// atomically remove ticket
			Transaction t = jedis.multi();
			t.del(key(repository, KeyType.ticket, ticket.number));
			t.del(key(repository, KeyType.journal, ticket.number));
			t.exec();

			success = true;
			log.debug("deleted ticket {} from Redis @ {}", "" + ticket.number, getUrl());
		} catch (JedisException e) {
			log.error("failed to delete ticket from Redis @ " + getUrl(), e);
			pool.returnBrokenResource(jedis);
			jedis = null;
		} finally {
			if (jedis != null) {
				pool.returnResource(jedis);
			}
		}

		return success;
	}

	/**
	 * Commit a ticket change to the repository.
	 *
	 * @param repository
	 * @param ticketId
	 * @param change
	 * @return true, if the change was committed
	 */
	@Override
	protected boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) {
		Jedis jedis = pool.getResource();
		if (jedis == null) {
			return false;
		}
		try {
			List<Change> changes = getJournal(jedis, repository, ticketId);
			changes.add(change);
			// build a new effective ticket from the changes
			TicketModel ticket = TicketModel.buildTicket(changes);

			String object = TicketSerializer.serialize(ticket);
			String journal = TicketSerializer.serialize(change);

			// atomically store ticket
			Transaction t = jedis.multi();
			t.set(key(repository, KeyType.ticket, ticketId), object);
			t.rpush(key(repository, KeyType.journal, ticketId), journal);
			t.exec();

			log.debug("updated ticket {} in Redis @ {}", "" + ticketId, getUrl());
			return true;
		} catch (JedisException e) {
			log.error("failed to update ticket cache in Redis @ " + getUrl(), e);
			pool.returnBrokenResource(jedis);
			jedis = null;
		} finally {
			if (jedis != null) {
				pool.returnResource(jedis);
			}
		}
		return false;
	}

	/**
	 *  Deletes all Tickets for the rpeository from the Redis key-value store.
	 *
	 */
	@Override
	protected boolean deleteAllImpl(RepositoryModel repository) {
		Jedis jedis = pool.getResource();
		if (jedis == null) {
			return false;
		}

		boolean success = false;
		try {
			Set<String> keys = jedis.keys(repository.name + ":*");
			if (keys.size() > 0) {
				Transaction t = jedis.multi();
				t.del(keys.toArray(new String[keys.size()]));
				t.exec();
			}
			success = true;
		} catch (JedisException e) {
			log.error("failed to delete all tickets in Redis @ " + getUrl(), e);
			pool.returnBrokenResource(jedis);
			jedis = null;
		} finally {
			if (jedis != null) {
				pool.returnResource(jedis);
			}
		}
		return success;
	}

	@Override
	protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) {
		Jedis jedis = pool.getResource();
		if (jedis == null) {
			return false;
		}

		boolean success = false;
		try {
			Set<String> oldKeys = jedis.keys(oldRepository.name + ":*");
			Transaction t = jedis.multi();
			for (String oldKey : oldKeys) {
				String newKey = newRepository.name + oldKey.substring(oldKey.indexOf(':'));
				t.rename(oldKey, newKey);
			}
			t.exec();
			success = true;
		} catch (JedisException e) {
			log.error("failed to rename tickets in Redis @ " + getUrl(), e);
			pool.returnBrokenResource(jedis);
			jedis = null;
		} finally {
			if (jedis != null) {
				pool.returnResource(jedis);
			}
		}
		return success;
	}

	private JedisPool createPool(String url) {
		JedisPool pool = null;
		if (!StringUtils.isEmpty(url)) {
			try {
				URI uri = URI.create(url);
				if (uri.getScheme() != null && uri.getScheme().equalsIgnoreCase("redis")) {
					int database = Protocol.DEFAULT_DATABASE;
					String password = null;
					if (uri.getUserInfo() != null) {
						password = uri.getUserInfo().split(":", 2)[1];
					}
					if (uri.getPath().indexOf('/') > -1) {
						database = Integer.parseInt(uri.getPath().split("/", 2)[1]);
					}
					pool = new JedisPool(new GenericObjectPoolConfig(), uri.getHost(), uri.getPort(), Protocol.DEFAULT_TIMEOUT, password, database);
				} else {
					pool = new JedisPool(url);
				}
			} catch (JedisException e) {
				log.error("failed to create a Redis pool!", e);
			}
		}
		return pool;
	}

	@Override
	public String toString() {
		String url = getUrl();
		return getClass().getSimpleName() + " (" + (url == null ? "DISABLED" : url) + ")";
	}
}
