/*
 * 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.fanout;

import java.io.IOException;
import java.net.Socket;
import java.net.SocketException;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Base class for Fanout service implementations.
 * 
 * Subclass implementations can be used as a Sparkleshare PubSub notification
 * server.  This allows Sparkleshare to be used in conjunction with Gitblit
 * behind a corporate firewall that restricts or prohibits client internet access
 * to the default Sparkleshare PubSub server: notifications.sparkleshare.org
 * 
 * @author James Moger
 *
 */
public abstract class FanoutService implements Runnable {

	private final static Logger logger = LoggerFactory.getLogger(FanoutService.class);
	
	public final static int DEFAULT_PORT = 17000;
	
	protected final static int serviceTimeout = 5000;

	protected final String host;
	protected final int port;
	protected final String name; 
	
	private Thread serviceThread;
	
	private final Map<String, FanoutServiceConnection> connections;
	private final Map<String, Set<FanoutServiceConnection>> subscriptions;

	protected final AtomicBoolean isRunning;
	private final AtomicBoolean strictRequestTermination;
	private final AtomicBoolean allowAllChannelAnnouncements;
	private final AtomicInteger concurrentConnectionLimit;
	
	private final Date bootDate;
	private final AtomicLong rejectedConnectionCount;
	private final AtomicInteger peakConnectionCount;
	private final AtomicLong totalConnections;
	private final AtomicLong totalAnnouncements;
	private final AtomicLong totalMessages;
	private final AtomicLong totalSubscribes;
	private final AtomicLong totalUnsubscribes;
	private final AtomicLong totalPings;

	protected FanoutService(String host, int port, String name) {
		this.host = host;
		this.port = port;
		this.name = name;
		
		connections = new ConcurrentHashMap<String, FanoutServiceConnection>();
		subscriptions = new ConcurrentHashMap<String, Set<FanoutServiceConnection>>();
		subscriptions.put(FanoutConstants.CH_ALL, new ConcurrentSkipListSet<FanoutServiceConnection>());
		
		isRunning = new AtomicBoolean(false);
		strictRequestTermination = new AtomicBoolean(false);
		allowAllChannelAnnouncements = new AtomicBoolean(false);
		concurrentConnectionLimit = new AtomicInteger(0);
		
		bootDate = new Date();
		rejectedConnectionCount = new AtomicLong(0);
		peakConnectionCount = new AtomicInteger(0);
		totalConnections = new AtomicLong(0);
		totalAnnouncements = new AtomicLong(0);
		totalMessages = new AtomicLong(0);
		totalSubscribes = new AtomicLong(0);
		totalUnsubscribes = new AtomicLong(0);
		totalPings = new AtomicLong(0);
	}

	/*
	 * Abstract methods
	 */
	
	protected abstract boolean isConnected();
	
	protected abstract boolean connect();
	
	protected abstract void listen() throws IOException;
	
	protected abstract void disconnect();
	
	/**
	 * Returns true if the service requires \n request termination.
	 * 
	 * @return true if request requires \n termination
	 */
	public boolean isStrictRequestTermination() {
		return strictRequestTermination.get();
	}

	/**
	 * Control the termination of fanout requests. If true, fanout requests must
	 * be terminated with \n. If false, fanout requests may be terminated with
	 * \n, \r, \r\n, or \n\r. This is useful for debugging with a telnet client.
	 * 
	 * @param isStrictTermination
	 */
	public void setStrictRequestTermination(boolean isStrictTermination) {
		strictRequestTermination.set(isStrictTermination);
	}
	
	/**
	 * Returns the maximum allowable concurrent fanout connections.
	 *  
	 * @return the maximum allowable concurrent connection count
	 */
	public int getConcurrentConnectionLimit() {
		return concurrentConnectionLimit.get();
	}
	
	/**
	 * Sets the maximum allowable concurrent fanout connection count.
	 * 
	 * @param value
	 */
	public void setConcurrentConnectionLimit(int value) {
		concurrentConnectionLimit.set(value);
	}
	
	/**
	 * Returns true if connections are allowed to announce on the all channel.
	 *  
	 * @return true if connections are allowed to announce on the all channel
	 */
	public boolean allowAllChannelAnnouncements() {
		return allowAllChannelAnnouncements.get();
	}
	
	/**
	 * Allows/prohibits connections from announcing on the ALL channel.
	 * 
	 * @param value
	 */
	public void setAllowAllChannelAnnouncements(boolean value) {
		allowAllChannelAnnouncements.set(value);
	}
	
	/**
	 * Returns the current connections
	 * 
	 * @param channel
	 * @return map of current connections keyed by their id
	 */
	public Map<String, FanoutServiceConnection> getCurrentConnections() {
		return connections;
	}
	
	/**
	 * Returns all subscriptions
	 * 
	 * @return map of current subscriptions keyed by channel name
	 */
	public Map<String, Set<FanoutServiceConnection>> getCurrentSubscriptions() {
		return subscriptions;
	}

	/**
	 * Returns the subscriptions for the specified channel
	 * 
	 * @param channel
	 * @return set of subscribed connections for the specified channel
	 */
	public Set<FanoutServiceConnection> getCurrentSubscriptions(String channel) {
		return subscriptions.get(channel);
	}

	/**
	 * Returns the runtime statistics object for this service.
	 * 
	 * @return stats
	 */
	public FanoutStats getStatistics() {
		FanoutStats stats = new FanoutStats();
		
		// settings
		stats.allowAllChannelAnnouncements = allowAllChannelAnnouncements();
		stats.concurrentConnectionLimit = getConcurrentConnectionLimit();
		stats.strictRequestTermination = isStrictRequestTermination();
		
		// runtime stats
		stats.bootDate = bootDate;
		stats.rejectedConnectionCount = rejectedConnectionCount.get();
		stats.peakConnectionCount = peakConnectionCount.get();
		stats.totalConnections = totalConnections.get();
		stats.totalAnnouncements = totalAnnouncements.get();
		stats.totalMessages = totalMessages.get();
		stats.totalSubscribes = totalSubscribes.get();
		stats.totalUnsubscribes = totalUnsubscribes.get();
		stats.totalPings = totalPings.get();		
		stats.currentConnections = connections.size();
		stats.currentChannels = subscriptions.size();
		stats.currentSubscriptions = subscriptions.size() * connections.size();
		return stats;
	}
	
	/**
	 * Returns true if the service is ready.
	 * 
	 * @return true, if the service is ready
	 */
	public boolean isReady() {
		if (isRunning.get()) {
			return isConnected();
		}
		return false;
	}

	/**
	 * Start the Fanout service thread and immediatel return.
	 * 
	 */
	public void start() {
		if (isRunning.get()) {
			logger.warn(MessageFormat.format("{0} is already running", name));
			return;
		}
		serviceThread = new Thread(this);
		serviceThread.setName(MessageFormat.format("{0} {1}:{2,number,0}", name, host == null ? "all" : host, port));
		serviceThread.start();
	}
	
	/**
	 * Start the Fanout service thread and wait until it is accepting connections.
	 * 
	 */
	public void startSynchronously() {
		start();
		while (!isReady()) {
			try {
				Thread.sleep(100);
			} catch (Exception e) {
			}
		}
	}
		
	/**
	 * Stop the Fanout service.  This method returns when the service has been
	 * completely shutdown.
	 */
	public void stop() {
		if (!isRunning.get()) {
			logger.warn(MessageFormat.format("{0} is not running", name));
			return;
		}
		logger.info(MessageFormat.format("stopping {0}...", name));
		isRunning.set(false);
		try {
			if (serviceThread != null) {
				serviceThread.join();
				serviceThread = null;
			}
		} catch (InterruptedException e1) {
			logger.error("", e1);
		}
		logger.info(MessageFormat.format("stopped {0}", name));
	}
	
	/**
	 * Main execution method of the service
	 */
	@Override
	public final void run() {
		disconnect();
		resetState();
		isRunning.set(true);
		while (isRunning.get()) {
			if (connect()) {
				try {
					listen();
				} catch (IOException e) {
					logger.error(MessageFormat.format("error processing {0}", name), e);
					isRunning.set(false);
				}
			} else {
				try {
					Thread.sleep(serviceTimeout);
				} catch (InterruptedException x) {
				}
			}
		}
		disconnect();		
		resetState();
	}
	
	protected void resetState() {
		// reset state data
		connections.clear();
		subscriptions.clear();
		rejectedConnectionCount.set(0);
		peakConnectionCount.set(0);
		totalConnections.set(0);
		totalAnnouncements.set(0);
		totalMessages.set(0);
		totalSubscribes.set(0);
		totalUnsubscribes.set(0);
		totalPings.set(0);
	}

	/**
	 * Configure the client connection socket.
	 * 
	 * @param socket
	 * @throws SocketException
	 */
	protected void configureClientSocket(Socket socket) throws SocketException {
		socket.setKeepAlive(true);				
		socket.setSoLinger(true, 0); // immediately discard any remaining data
	}
	
	/**
	 * Add the connection to the connections map.
	 * 
	 * @param connection
	 * @return false if the connection was rejected due to too many concurrent
	 *         connections
	 */
	protected boolean addConnection(FanoutServiceConnection connection) {		
		int limit = getConcurrentConnectionLimit();
		if (limit > 0 && connections.size() > limit) {
			logger.info(MessageFormat.format("hit {0,number,0} connection limit, rejecting fanout connection", concurrentConnectionLimit));
			increment(rejectedConnectionCount);
			connection.busy();
			return false;
		}
		
		// add the connection to our map
		connections.put(connection.id, connection);

		// track peak number of concurrent connections
		if (connections.size() > peakConnectionCount.get()) {
			peakConnectionCount.set(connections.size());
		}

		logger.info("fanout new connection " + connection.id);
		connection.connected();
		return true;
	}
	
	/**
	 * Remove the connection from the connections list and from subscriptions.
	 * 
	 * @param connection
	 */
	protected void removeConnection(FanoutServiceConnection connection) {
		connections.remove(connection.id);
		Iterator<Map.Entry<String, Set<FanoutServiceConnection>>> itr = subscriptions.entrySet().iterator();
		while (itr.hasNext()) {
			Map.Entry<String, Set<FanoutServiceConnection>> entry = itr.next();
			Set<FanoutServiceConnection> subscriptions = entry.getValue();
			subscriptions.remove(connection);
			if (!FanoutConstants.CH_ALL.equals(entry.getKey())) {
				if (subscriptions.size() == 0) {
					itr.remove();
					logger.info(MessageFormat.format("fanout remove channel {0}, no subscribers", entry.getKey()));
				}
			}
		}
		logger.info(MessageFormat.format("fanout connection {0} removed", connection.id));
	}
	
	/**
	 * Tests to see if the connection is being monitored by the service.
	 * 
	 * @param connection
	 * @return true if the service is monitoring the connection
	 */
	protected boolean hasConnection(FanoutServiceConnection connection) {
		return connections.containsKey(connection.id);
	}
	
	/**
	 * Reply to a connection on the specified channel.
	 * 
	 * @param connection
	 * @param channel
	 * @param message
	 * @return the reply
	 */
	protected String reply(FanoutServiceConnection connection, String channel, String message) {		
		if (channel != null && channel.length() > 0) {
			increment(totalMessages);
		}
		return connection.reply(channel, message);
	}
	
	/**
	 * Service method to broadcast a message to all connections.
	 * 
	 * @param message
	 */
	public void broadcastAll(String message) {
		broadcast(connections.values(), FanoutConstants.CH_ALL, message);
		increment(totalAnnouncements);
	}
	
	/**
	 * Service method to broadcast a message to connections subscribed to the
	 * channel.
	 * 
	 * @param message
	 */
	public void broadcast(String channel, String message) {
		List<FanoutServiceConnection> connections = new ArrayList<FanoutServiceConnection>(subscriptions.get(channel));
		broadcast(connections, channel, message);
		increment(totalAnnouncements);
	}
	
	/**
	 * Broadcast a message to connections subscribed to the specified channel.
	 * 
	 * @param connections
	 * @param channel
	 * @param message
	 */
	protected void broadcast(Collection<FanoutServiceConnection> connections, String channel, String message) {
		for (FanoutServiceConnection connection : connections) {
			reply(connection, channel, message);
		}
	}
	
	/**
	 * Process an incoming Fanout request.
	 * 
	 * @param connection
	 * @param req
	 * @return the reply to the request, may be null
	 */
	protected String processRequest(FanoutServiceConnection connection, String req) {
		logger.info(MessageFormat.format("fanout request from {0}: {1}", connection.id, req));
		String[] fields = req.split(" ", 3);
		String action = fields[0];
		String channel = fields.length >= 2 ? fields[1] : null;
		String message = fields.length >= 3 ? fields[2] : null;
		try {
			return processRequest(connection, action, channel, message);
		} catch (IllegalArgumentException e) {
			// invalid action
			logger.error(MessageFormat.format("fanout connection {0} requested invalid action {1}", connection.id, action));
			logger.error(asHexArray(req));
		}
		return null;
	}
	
	/**
	 * Process the Fanout request.
	 * 
	 * @param connection
	 * @param action
	 * @param channel
	 * @param message
	 * @return the reply to the request, may be null
	 * @throws IllegalArgumentException
	 */
	protected String processRequest(FanoutServiceConnection connection, String action, String channel, String message) throws IllegalArgumentException {
		if ("ping".equals(action)) {
			// ping
			increment(totalPings);
			return reply(connection, null, "" + System.currentTimeMillis());
		} else if ("info".equals(action)) {
			// info
			String info = getStatistics().info();
			return reply(connection, null, info);
		} else if ("announce".equals(action)) {
			// announcement
			if (!allowAllChannelAnnouncements.get() && FanoutConstants.CH_ALL.equals(channel)) {
				// prohibiting connection-sourced all announcements
				logger.warn(MessageFormat.format("fanout connection {0} attempted to announce {1} on ALL channel", connection.id, message));
			} else if ("debug".equals(channel)) {
				// prohibiting connection-sourced debug announcements
				logger.warn(MessageFormat.format("fanout connection {0} attempted to announce {1} on DEBUG channel", connection.id, message));
			} else {
				// acceptable announcement
				List<FanoutServiceConnection> connections = new ArrayList<FanoutServiceConnection>(subscriptions.get(channel));
				connections.remove(connection); // remove announcer
				broadcast(connections, channel, message);
				increment(totalAnnouncements);
			}
		} else if ("subscribe".equals(action)) {
			// subscribe
			if (!subscriptions.containsKey(channel)) {
				logger.info(MessageFormat.format("fanout new channel {0}", channel));
				subscriptions.put(channel, new ConcurrentSkipListSet<FanoutServiceConnection>());
			}
			subscriptions.get(channel).add(connection);
			logger.debug(MessageFormat.format("fanout connection {0} subscribed to channel {1}", connection.id, channel));
			increment(totalSubscribes);
		} else if ("unsubscribe".equals(action)) {
			// unsubscribe
			if (subscriptions.containsKey(channel)) {
				subscriptions.get(channel).remove(connection);
				if (subscriptions.get(channel).size() == 0) {
					subscriptions.remove(channel);
				}
				increment(totalUnsubscribes);
			}
		} else {
			// invalid action
			throw new IllegalArgumentException(action);
		}
		return null;
	}
	
	private String asHexArray(String req) {
		StringBuilder sb = new StringBuilder();
		for (char c : req.toCharArray()) {
			sb.append(Integer.toHexString(c)).append(' ');
		}
		return "[ " + sb.toString().trim() + " ]";
	}
	
	/**
	 * Increment a long and prevent negative rollover.
	 * 
	 * @param counter
	 */
	private void increment(AtomicLong counter) {
		long v = counter.incrementAndGet();
		if (v < 0) {
			counter.set(0);
		}
	}
	
	@Override
	public String toString() {
		return name;
	}
}