package com.socialize.cache;

import com.socialize.log.SocializeLogger;

import java.util.*;

/**
 * Simple cache object backed by a synchronized map which allows a TTL (Time To Live) for objects in cache.
 * @author Jason Polites
 */
public class TTLCache<K extends Comparable<K>, E extends ICacheable<K>> {

	public static int DEFAULT_CACHE_COUNT = 1000;

	private TreeMap<Key<K>, TTLObject<K, E>> objects;
	private Map<K, Key<K>> keys;

	private SocializeLogger logger;

	private boolean debug = false;
	private boolean extendOnGet = true;
	private long reapCycle = 60000L; // 1 minute
	private long defaultTTL = 60 * 60 * 1000; // 1 hour

	private int maxCapacity = -1;
	private long maxCapacityBytes = -1;

	private long currentSizeInBytes = 0;

	/**
	 * If true the cache will not accept new additions if the max bytes would be exceeded.
	 * If false, over sized objects are removed during reap.
	 */
	private boolean hardByteLimit = false;

	private ICacheEventListener<K, E> eventListener;
	protected ICacheableFactory<K, E> objectFactory;

	private static Timer reapTimer;
	private Reaper reaper;

	private boolean reaping = false;

	protected class Reaper extends TimerTask {
		public void run() {
			reap();
		}
	}

	public TTLCache() {
		this(10, DEFAULT_CACHE_COUNT);
	}

	public TTLCache(int initialCapacity) {
		this(initialCapacity, DEFAULT_CACHE_COUNT);
	}

	public TTLCache(int initialCapacity, int maxCapacity) {
		super();
		this.maxCapacity = maxCapacity;
		objects = makeMap();
		keys = new HashMap<K, Key<K>>(initialCapacity);

		// Start reaper
		startReaper();
	}

	/**
	 * Empties the cache and destroys all persistent states.
	 */
	public void destroy() {
		stopReaper();
		clear(true);
	}

	/**
	 * Empties the case and should remove any persistent states.
	 */
	public void clear() {
		clear(false);
	}

	public void pause() {
		stopReaper();
	}

	public void resume() {
		startReaper();
	}

	protected synchronized void clear(boolean destroy) {
		keys.clear();

		Collection<TTLObject<K, E>> values = objects.values();

		for (TTLObject<K, E> ttlObject : values) {
			ttlObject.getObject().onRemove(destroy);
		}

		objects.clear();
		currentSizeInBytes = 0;
	}

	public void setReapCycle(long milliseconds) {
		reapCycle = milliseconds;
		startReaper();
	}

	protected synchronized void startReaper() {
		stopReaper();

		if(reapCycle > 0) {
			reaper = new Reaper();

			if(reapTimer == null) {
				reapTimer = new Timer("CacheReaper", true); // Daemon so we auto-exit on shutdown
			}

			reapTimer.schedule(reaper, reapCycle, reapCycle);
		}
	}

	protected synchronized void stopReaper() {
		if(reaper != null) {
			reaper.cancel();
			reaper = null;
		}

		if(reapTimer != null) {
			reapTimer.purge();
		}

		reaping = false;
	}

	/**
	 * Adds an object to cache with the given time-to-live
	 * @param strKey
	 * @param object
	 * @param ttl
	 */
	public boolean put(K strKey, E object, long ttl) {
		return put(strKey, object, ttl, false);
	}

	/**
	 * Adds an object to cache that optionally lives forever.
	 * @param strKey
	 * @param object
	 * @param eternal
	 * @return
	 */
	public boolean put(K strKey, E object, boolean eternal) {
		return put(strKey, object, defaultTTL, eternal);
	}

	/**
	 * Adds an eternal object to cache.  Eternal objects will never expire, unless they commit suicide.
	 * @param strKey
	 * @param object
	 */
	public boolean put(K strKey, E object) {
		return put(strKey, object, defaultTTL, (defaultTTL <= 0));
	}

	/**
	 * Adds an object to cache with the given Time To Live in milliseconds
	 * @param k
	 * @param object
	 * @param ttl milliseconds
	 * @param eternal
	 */
	protected synchronized boolean put(K k, E object, long ttl, boolean eternal) {

		// Check the key map first
		if(exists(k)) {
			TTLObject<K, E> ttlObject = getTTLObject(k);

			Key<K> key = keys.get(k);
			key.setTime(System.currentTimeMillis());

			ttlObject.setEternal(eternal);
			ttlObject.extendLife(ttl);
			ttlObject.setObject(object);

			if(eventListener != null) {
				eventListener.onPut(object);
			}

			return true;
		}
		else {
			TTLObject<K, E> t = new TTLObject<K, E>(object, k, ttl);

			t.setEternal(eternal);

			long addedSize = object.getSizeInBytes();
			long newSize = currentSizeInBytes + addedSize;
			boolean oversize = false;

			oversize = (hardByteLimit && maxCapacityBytes > 0 && newSize > maxCapacityBytes);

			if(!oversize) {
				Key<K> key = new Key<K>(k, System.currentTimeMillis());
				keys.put(k, key);
				objects.put(key, t);

				t.getObject().onPut(k);

				// Increment size
				currentSizeInBytes = newSize;

				if(eventListener != null) {
					eventListener.onPut(object);
				}

				return true;
			}
		}

		return false;
	}

	protected TTLObject<K, E> getTTLObject(K strKey) {
		TTLObject<K, E> obj = null;

		// Look for the key
		Key<K> key = keys.get(strKey);
		if(key != null) {
			obj = objects.get(key);
		}

		return obj;
	}

	/**
	 * Ignores proxy and always returns raw object
	 * @param strKey
	 * @return
	 */
	public synchronized E getRaw(K strKey) {
		TTLObject<K, E> obj = getTTLObject(strKey);
		if(obj != null && !isExpired(obj)) {
			return obj.getObject();
		}
		return null;
	}

	/**
	 * Gets an object from cache.  Returns null if the object does not exist, or has expired.
	 * @param key
	 * @return
	 */
	public synchronized E get(K key) {
		TTLObject<K, E> obj = getTTLObject(key);
		if(obj != null && !isExpired(obj)) {

			if(extendOnGet) {
				extendTTL(key);
			}

			if(eventListener != null) {
				eventListener.onGet(obj.getObject());
			}

			obj.getObject().onGet();

			return obj.getObject();

		}
		else if(obj != null) {
			// Expired
			destroy(obj.getKey());
			obj = null;
		}

		if (obj == null) {
			if(objectFactory != null) {
				E object = objectFactory.create(key);

				if(object != null) {
					if(!put(key, object) && logger != null) {
						// We couldn't put this record.. just log a warning
						logger.warn("Failed to put object into cache. Cache size exceeded");
					}
				}

				return object;
			}
		}

		return null;
	}

	/**
	 * Returns the internal values of the cache.
	 * <br/>
	 * Proxy objects are returned if gets are proxied within the cache.
	 * @return
	 */
	public Collection<E> values() {
		Collection<E> values = null;
		Collection<TTLObject<K, E>> ttls = objects.values();
		if(ttls != null) {
			values = new ArrayList<E>(ttls.size());
			for (TTLObject<K, E> t : ttls) {
				if(!isExpired(t)) {
					values.add(t.getObject());
				}
			}
		}
		return values;
	}

	/**
	 * Returns true if the object with the given key resides in the cache.
	 * @param k
	 * @return true if the object with the given key resides in the cache.
	 */
	public boolean exists(K k) {
		Key<K> key = keys.get(k);
		if(key != null) {
			return objects.get(key) != null;
		}
		return false;
	}

	public boolean keyExists(K k) {
		return keys.get(k) != null;
	}

	/**
	 * Removes an object from cache.  If the object maintains a persistent state.
	 * @param key
	 */
	public E remove(K key) {
		return remove(key, false);
	}

	/**
	 * Destroys an object in cache.  This differs from remove() such that any persistent state associated with the object should also be removed.
	 * @param key
	 */
	public E destroy(K key) {
		return remove(key, true);
	}

	public synchronized E remove(K strKey, boolean destroy) {
		Key<K> key = keys.get(strKey);
		TTLObject<K, E> removed = null;
		if(key != null) {
			removed = objects.remove(key);

			if(removed != null) {
				currentSizeInBytes -= removed.getObject().getSizeInBytes();
				removed.getObject().onRemove(destroy);
			}

			keys.remove(strKey);
		}

		if(removed != null) {
			return removed.getObject();
		}

		return null;
	}

	/**
	 * Extends the ttl of the object with the given key with the current system time.
	 * @param strKey
	 */
	public synchronized void extendTTL(K strKey) {
		TTLObject<K, E> object = getTTLObject(strKey);
		if(object != null) {
			object.setLifeExpectancy(System.currentTimeMillis() + object.getTtl());
		}
	}

	/**
	 * @return Returns the debug.
	 */
	public boolean isDebug() {
		return debug;
	}

	/**
	 * @param debug The debug to set.
	 */
	public void setDebug(boolean debug) {
		this.debug = debug;
	}

	public boolean isExpired(TTLObject<K, E> object) {
		E o = object.getObject();

		if(o instanceof ISuicidal) {
			ISuicidal<K> s = (ISuicidal<K>) o;
			if(s.isDead()) {
				return true;
			}
		}
		return !object.isEternal() && object.getLifeExpectancy() <= System.currentTimeMillis();
	}

	public boolean doReap() {
		return reap();
	}

	protected synchronized boolean reap() {

		if(!reaping) {

			int reaped = 0;

			try {
				reaping = true;

				if(eventListener != null) {
					eventListener.onReapStart();
				}

				int size = objects.size();

				if(size > 0) {

					Set<Key<K>> localKeys = objects.keySet();

					TreeMap<Key<K>, TTLObject<K, E>> newMap = makeMap();

					long time = System.currentTimeMillis();

					boolean ok = true;

					TTLObject<K, E> object = null;

					for (Key<K> key : localKeys) {
						object = objects.get(key);

						if(object != null) {
							ok = true;

							if(object.getObject() instanceof ISuicidal) {
								ISuicidal<K> s = (ISuicidal<K>) object.getObject();

								if(s.isDead()) {

									size--;

									if(debug && logger != null) {

										String msg = "Object [" +
												object.getObject().toString() +
												"] has comitted suicide and will be purged from cache [" +
												size +
												"] objects remain";

										logger.debug(msg);
									}

									ok = false;
								}
							}

							if(ok) {
								if(object.isEternal() || object.getLifeExpectancy() >= time) {
									// Save
									newMap.put(key, object);
								}
								else {

									ok = false;

									size--;

									if(debug && logger != null) {

										String msg = "Object [" +
												object.getObject().toString() +
												"] with ttl of [" +
												object.getTtl() +
												"] has expired and will be purged from cache [" +
												size +
												"] objects remain";

										logger.debug(msg);
									}
								}
							}

							if(!ok) {

								if(logger != null && logger.isDebugEnabled()) {
									logger.debug("Removing with key [" +
											key +
											"] during reap");
								}

								keys.remove(key.getKey());

								currentSizeInBytes -= object.getObject().getSizeInBytes();

								reaped++;

								object.getObject().onRemove(true);
							}
						}
						else {
							if(logger != null) {
								logger.warn("No object found with key [" +
										key +
										"]");

								objects.remove(key);
							}
						}
					}

					// Now check for over size
					size = newMap.size();

					if((maxCapacity > 0 && size > maxCapacity) || (maxCapacityBytes > 0 && currentSizeInBytes > maxCapacityBytes)) {
						// To many objects.. start trimming
						if(debug && logger != null) {
							String msg = "TTLCache has count of [" +
									size +
									"], size of [" +
									currentSizeInBytes +
									"] bytes  which exceeds maximum of [" +
									maxCapacity +
									"], [" +
									maxCapacityBytes +
									"] bytes.  Excess items will be trimmed";

							logger.debug(msg);

						}

						Key<K> key = null;

						while((maxCapacity > 0 && size > maxCapacity) || (maxCapacityBytes > 0 && currentSizeInBytes > maxCapacityBytes)) {
							key = newMap.firstKey();

							if(debug && logger != null) {
								String msg = "Removing item with key [" +
										key +
										"] from cache";

								logger.debug( msg);
							}

							TTLObject<K, E> removed = newMap.remove(key);

							keys.remove(key.getKey());

							size = newMap.size();

							currentSizeInBytes -= removed.getObject().getSizeInBytes();

							removed.getObject().onRemove(true);

							reaped++;

							if(debug && logger != null) {
								String msg = "[" +
										size +
										"] objects remain with size of [" +
										currentSizeInBytes +
										"]";

								logger.debug(msg);
							}
						}
					}

					// Swap
					objects = newMap;
				}
			}
			finally {

				if(eventListener != null) {
					eventListener.onReapEnd(reaped);
				}

				reaping = false;
			}

			return true;
		}

		return false;
	}

	protected TreeMap<Key<K>, TTLObject<K, E>> makeMap() {
		return new TreeMap<Key<K>, TTLObject<K, E>>();
	}

	public int size() {
		return objects.size();
	}

	public long sizeInBytes() {
		return currentSizeInBytes;
	}

	public boolean isExtendOnGet() {
		return extendOnGet;
	}

	public void setExtendOnGet(boolean extendOnGet) {
		this.extendOnGet = extendOnGet;
	}

	public long getMaxCapacityBytes() {
		return maxCapacityBytes;
	}

	public void setMaxCapacityBytes(long maxCapacityBytes) {
		this.maxCapacityBytes = maxCapacityBytes;
	}

	public ICacheEventListener<K, E> getEventListener() {
		return eventListener;
	}

	public void setEventListener(ICacheEventListener<K, E> eventListener) {
		this.eventListener = eventListener;
	}

	/**
	 * Extends the maximum capacity.
	 * @param extension
	 */
	public void extendMax(int extension) {
		maxCapacity += extension;
	}

	/**
	 * @return the maxCapacity
	 */
	public int getMaxCapacity() {
		return maxCapacity;
	}

	public ICacheableFactory<K, E> getObjectFactory() {
		return objectFactory;
	}

	public void setObjectFactory(ICacheableFactory<K, E> constructor) {
		this.objectFactory = constructor;
	}

	public long getDefaultTTL() {
		return defaultTTL;
	}

	public void setDefaultTTL(long defaultTTL) {
		this.defaultTTL = defaultTTL;
	}

	public boolean isHardByteLimit() {
		return hardByteLimit;
	}

	public void setHardByteLimit(boolean hardByteLimit) {
		this.hardByteLimit = hardByteLimit;
	}

	public SocializeLogger getLogger() {
		return logger;
	}

	public void setLogger(SocializeLogger logger) {
		this.logger = logger;
	}
}


/*
 * Copyright (c) 2012 Socialize Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package com.socialize.ui.image;

import com.socialize.log.SocializeLogger;
import com.socialize.util.CacheableDrawable;
import com.socialize.util.Drawables;

/**
 * Loads images from a url.
 * @author Jason Polites
 */
public class ImageLoader {

	private Drawables drawables;
	private ImageLoadAsyncTask imageLoadAsyncTask;
	private SocializeLogger logger;

	/**
	 * Initializes the loader and starts an AsyncTask thread.
	 */
	public void init() {
		if(logger != null && logger.isDebugEnabled()) {
			logger.debug("ImageLoader starting image load task");
		}
		imageLoadAsyncTask.start();
	}

	/**
	 * Destroys the loader and stops the AsyncTask thread.
	 */
	public void destroy() {
		if(logger != null && logger.isDebugEnabled()) {
			logger.debug("ImageLoader stopping image load task");
		}
		imageLoadAsyncTask.finish();
	}

	/**
	 * Cancels the load of a previous request.
	 * @param url
	 */
	public void cancel(String url) {
		imageLoadAsyncTask.cancel(url);
	}

	/**
	 * Loads an image based on Base64 encoded bytes.
	 * @param id The id to give the request
	 * @param name The name to give the image in cache.
	 * @param encodedData The encoded image data.
	 * @param listener A listener to handle the callback.
	 */
	public void loadImageByData(final String name, final String encodedData, int width, int height, final ImageLoadListener listener) {
		ImageLoadRequest request = makeRequest();
		request.setUrl(name);
		request.setEncodedImageData(encodedData);
		request.setType(ImageLoadType.ENCODED);
		loadImage(request, listener);
	}

	public void loadImageByUrl(final String url, final ImageLoadListener listener) {
		loadImageByUrl(url, -1, -1, listener);
	}

	/**
	 * Asynchronously loads the image at the given url and calls the listener when it is loaded.
	 * @param id The id to give the request
	 * @param url The url to be loaded
	 * @param listener A listener to handle the callback.
	 */
	public void loadImageByUrl(final String url, int width, int height, final ImageLoadListener listener) {
		ImageLoadRequest request = makeRequest();
		request.setUrl(url);
		request.setType(ImageLoadType.URL);
		request.setScaleWidth(width);
		request.setScaleHeight(height);
		loadImage(request, listener);
	}

	public void loadImage(final ImageLoadRequest request, final ImageLoadListener listener) {
		// Look in cache
		final String url = request.getUrl();

		CacheableDrawable drawable = drawables.getCache().get(url);

		if(drawable != null && !drawable.isRecycled()) {
			if(logger != null && logger.isDebugEnabled()) {
				logger.debug("ImageLoader loading image from cache for " + url);
			}

			if(listener != null) {
				listener.onImageLoad(request, drawable);
			}
		}
		else {

			if(logger != null && logger.isDebugEnabled()) {

				if(drawable != null && drawable.isRecycled()) {
					logger.debug("ImageLoader image was recycled, reloading " + url);
				}
				else {
					logger.debug("ImageLoader enqueuing request for image " + url);
				}

			}

			if(listener != null) {
				request.addListener(listener);
			}

			imageLoadAsyncTask.enqueue(request);
		}
	}

	public Drawables getDrawables() {
		return drawables;
	}

	public void setDrawables(Drawables drawables) {
		this.drawables = drawables;
	}

	protected ImageLoadRequest makeRequest() {
		return new ImageLoadRequest();
	}

	public void setImageLoadAsyncTask(ImageLoadAsyncTask imageLoadAsyncTask) {
		this.imageLoadAsyncTask = imageLoadAsyncTask;
	}

	public void setLogger(SocializeLogger logger) {
		this.logger = logger;
	}

	public boolean isLoading(String url) {
		return imageLoadAsyncTask.isLoading(url);
	}

	public boolean isEmpty() {
		return imageLoadAsyncTask.isEmpty();
	}
}


/*
 * Copyright (c) 2012 Socialize Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package com.socialize.api.action.like;

import com.socialize.api.SocializeApi;
import com.socialize.api.SocializeSession;
import com.socialize.config.SocializeConfig;
import com.socialize.entity.Entity;
import com.socialize.entity.Like;
import com.socialize.entity.User;
import com.socialize.error.SocializeApiError;
import com.socialize.error.SocializeException;
import com.socialize.listener.like.LikeListListener;
import com.socialize.listener.like.LikeListener;
import com.socialize.networks.SocialNetwork;
import com.socialize.provider.SocializeProvider;

import java.util.ArrayList;
import java.util.List;

/**
 * @author Jason Polites
 */
public class SocializeLikeSystem extends SocializeApi<Like, SocializeProvider<Like>> implements LikeSystem {

	public SocializeLikeSystem(SocializeProvider<Like> provider) {
		super(provider);
	}

	/*
	 * (non-Javadoc)
	 * @see com.socialize.api.action.LikeSystem#addLike(com.socialize.api.SocializeSession, com.socialize.entity.Entity, android.location.Location, com.socialize.networks.ShareOptions, com.socialize.listener.like.LikeListener)
	 */
	@Override
	public void addLike(SocializeSession session, Entity entity, LikeOptions shareOptions, LikeListener listener, SocialNetwork... networks) {
		Like c = new Like();
		c.setEntitySafe(entity);

		setPropagationData(c, shareOptions, networks);
		setLocation(c);

		List<Like> list = new ArrayList<Like>(1);
		list.add(c);

		postAsync(session, ENDPOINT, list, listener);
	}

	/* (non-Javadoc)
	 * @see com.socialize.api.action.LikeSystem#deleteLike(com.socialize.api.SocializeSession, long, com.socialize.listener.like.LikeListener)
	 */
	@Override
	public void deleteLike(SocializeSession session, long id, LikeListener listener) {
		deleteAsync(session, ENDPOINT, String.valueOf(id), listener);
	}

	/* (non-Javadoc)
	 * @see com.socialize.api.action.LikeSystem#getLikesByEntity(com.socialize.api.SocializeSession, java.lang.String, com.socialize.listener.like.LikeListener)
	 */
	@Override
	public void getLikesByEntity(SocializeSession session, String key, LikeListener listener) {
		listAsync(session, ENDPOINT, key, listener);
	}

	public void getLikesByUser(SocializeSession session, long userId, LikeListener listener) {
		String endpoint = "/user/" + userId + ENDPOINT;
		listAsync(session, endpoint, listener);
	}

	public void getLikesByUser(SocializeSession session, long userId, int startIndex, int endIndex, LikeListener listener) {
		String endpoint = "/user/" + userId + ENDPOINT;
		listAsync(session, endpoint, startIndex, endIndex, listener);
	}

	/* (non-Javadoc)
	 * @see com.socialize.api.action.LikeSystem#getLikesByEntity(com.socialize.api.SocializeSession, java.lang.String, int, int, com.socialize.listener.like.LikeListener)
	 */
	@Override
	public void getLikesByEntity(SocializeSession session, String key, int startIndex, int endIndex, LikeListener listener) {
		listAsync(session, ENDPOINT, key, null, null, startIndex, endIndex, listener);
	}

	/*
	 * (non-Javadoc)
	 * @see com.socialize.api.action.like.LikeSystem#getLikesByApplication(com.socialize.api.SocializeSession, int, int, com.socialize.listener.like.LikeListener)
	 */
	@Override
	public void getLikesByApplication(SocializeSession session, int startIndex, int endIndex, LikeListener listener) {
		listAsync(session, ENDPOINT, null, null, null, startIndex, endIndex, listener);
	}

	/* (non-Javadoc)
	 * @see com.socialize.api.action.LikeSystem#getLike(com.socialize.api.SocializeSession, java.lang.String, com.socialize.listener.like.LikeListener)
	 */
	@Override
	public void getLike(SocializeSession session, final String entityUrl, final LikeListener listener) {
		final User user = session.getUser();
		if(user != null) {

			final Long userId = user.getId();

			String endpoint = "/user/" + userId.toString() + ENDPOINT;
			listAsync(session, endpoint, entityUrl, null, null, 0, 1, new LikeListListener() {

				@Override
				public void onList(List<Like> items, int totalSize) {

					boolean is404 = false;
					if(items != null) {
						if(items != null && items.size() > 0) {
							Like like = items.get(0);
							if(like != null) {
								listener.onGet(like);
							}
							else {
								is404 = true;
							}
						}
						else {
							is404 = true;
						}
					}
					else {
						is404 = true;
					}

					if(is404) {
						onError(new SocializeApiError(404, "No likes found for entity with key [" +
								entityUrl +
								"] for user [" +
								userId +
								"]"));
					}
				}
				@Override
				public void onError(SocializeException error) {
					listener.onError(error);
				}
			});
		}
		else {
			if(listener != null) {
				listener.onError(new SocializeException("No user found in current session"));
			}
		}
	}

	/* (non-Javadoc)
	 * @see com.socialize.api.action.LikeSystem#getLikesById(com.socialize.api.SocializeSession, com.socialize.listener.like.LikeListener, int)
	 */
	@Override
	public void getLikesById(SocializeSession session, LikeListener listener, long...ids) {

		if(ids != null) {
			String[] strIds = new String[ids.length];

			for (int i = 0; i < ids.length; i++) {
				strIds[i] = String.valueOf(ids[i]);
			}

			listAsync(session, ENDPOINT, null, 0, SocializeConfig.MAX_LIST_RESULTS, listener, strIds);
		}
		else {
			if(listener != null) {
				listener.onError(new SocializeException("No ids supplied"));
			}
		}
	}

	/* (non-Javadoc)
	 * @see com.socialize.api.action.LikeSystem#getLike(com.socialize.api.SocializeSession, long, com.socialize.listener.like.LikeListener)
	 */
	@Override
	public void getLike(SocializeSession session, long id, LikeListener listener) {
		getAsync(session, ENDPOINT, String.valueOf(id), listener);
	}
}


/*
 * ====================================================================
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.
 * ====================================================================
 *
 * This software consists of voluntary contributions made by many
 * individuals on behalf of the Apache Software Foundation.  For more
 * information on the Apache Software Foundation, please see
 * <http://www.apache.org/>.
 *
 */

package com.socialize.apache.http.entity.mime;

import com.socialize.apache.http.entity.mime.content.ContentBody;
import org.apache.http.util.ByteArrayBuffer;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;

/**
 * HttpMultipart represents a collection of MIME multipart encoded content
 * bodies. This class is capable of operating either in the strict (RFC 822, RFC
 * 2045, RFC 2046 compliant) or the browser compatible modes.
 *
 * @since 4.0
 */
public class HttpMultipart {

	private static ByteArrayBuffer encode(final Charset charset, final String string) {
		ByteBuffer encoded = charset.encode(CharBuffer.wrap(string));
		ByteArrayBuffer bab = new ByteArrayBuffer(encoded.remaining());
		bab.append(encoded.array(), encoded.position(), encoded.remaining());
		return bab;
	}

	private static void writeBytes(final ByteArrayBuffer b, final OutputStream out) throws IOException {
		out.write(b.buffer(), 0, b.length());
	}

	private static void writeBytes(final String s, final Charset charset, final OutputStream out) throws IOException {
		ByteArrayBuffer b = encode(charset, s);
		writeBytes(b, out);
	}

	private static void writeBytes(final String s, final OutputStream out) throws IOException {
		ByteArrayBuffer b = encode(MIME.DEFAULT_CHARSET, s);
		writeBytes(b, out);
	}

	private static void writeField(final MinimalField field, final OutputStream out) throws IOException {
		writeBytes(field.getName(), out);
		writeBytes(FIELD_SEP, out);
		writeBytes(field.getBody(), out);
		writeBytes(CR_LF, out);
	}

	private static void writeField(final MinimalField field, final Charset charset, final OutputStream out) throws IOException {
		writeBytes(field.getName(), charset, out);
		writeBytes(FIELD_SEP, out);
		writeBytes(field.getBody(), charset, out);
		writeBytes(CR_LF, out);
	}

	private static final ByteArrayBuffer FIELD_SEP = encode(MIME.DEFAULT_CHARSET, ": ");
	private static final ByteArrayBuffer CR_LF = encode(MIME.DEFAULT_CHARSET, "\r\n");
	private static final ByteArrayBuffer TWO_DASHES = encode(MIME.DEFAULT_CHARSET, "--");

	private final String subType;
	private final Charset charset;
	private final String boundary;
	private final List<FormBodyPart> parts;

	private final HttpMultipartMode mode;

	/**
	 * Creates an instance with the specified settings.
	 *
	 * @param subType
	 *            mime subtype - must not be {@code null}
	 * @param charset
	 *            the character set to use. May be {@code null}, in which case
	 *            {@link MIME#DEFAULT_CHARSET} - i.e. US-ASCII - is used.
	 * @param boundary
	 *            to use - must not be {@code null}
	 * @param mode
	 *            the mode to use
	 * @throws IllegalArgumentException
	 *             if charset is null or boundary is null
	 */
	public HttpMultipart(final String subType, final Charset charset, final String boundary, HttpMultipartMode mode) {
		super();
		if (subType == null) {
			throw new IllegalArgumentException("Multipart subtype may not be null");
		}
		if (boundary == null) {
			throw new IllegalArgumentException("Multipart boundary may not be null");
		}
		this.subType = subType;
		this.charset = charset != null ? charset : MIME.DEFAULT_CHARSET;
		this.boundary = boundary;
		this.parts = new ArrayList<FormBodyPart>();
		this.mode = mode;
	}

	/**
	 * Creates an instance with the specified settings. Mode is set to
	 * {@link HttpMultipartMode#STRICT}
	 *
	 * @param subType
	 *            mime subtype - must not be {@code null}
	 * @param charset
	 *            the character set to use. May be {@code null}, in which case
	 *            {@link MIME#DEFAULT_CHARSET} - i.e. US-ASCII - is used.
	 * @param boundary
	 *            to use - must not be {@code null}
	 * @throws IllegalArgumentException
	 *             if charset is null or boundary is null
	 */
	public HttpMultipart(final String subType, final Charset charset, final String boundary) {
		this(subType, charset, boundary, HttpMultipartMode.STRICT);
	}

	public HttpMultipart(final String subType, final String boundary) {
		this(subType, null, boundary);
	}

	public String getSubType() {
		return this.subType;
	}

	public Charset getCharset() {
		return this.charset;
	}

	public HttpMultipartMode getMode() {
		return this.mode;
	}

	public List<FormBodyPart> getBodyParts() {
		return this.parts;
	}

	public void addBodyPart(final FormBodyPart part) {
		if (part == null) {
			return;
		}
		this.parts.add(part);
	}

	public String getBoundary() {
		return this.boundary;
	}

	private void doWriteTo(final HttpMultipartMode mode, final OutputStream out, boolean writeContent) throws IOException {

		ByteArrayBuffer boundary = encode(this.charset, getBoundary());
		for (FormBodyPart part : this.parts) {
			writeBytes(TWO_DASHES, out);
			writeBytes(boundary, out);
			writeBytes(CR_LF, out);

			Header header = part.getHeader();

			switch (mode) {
			case STRICT:
				for (MinimalField field : header) {
					writeField(field, out);
				}
				break;
			case BROWSER_COMPATIBLE:
				// Only write Content-Disposition
				// Use content charset
				MinimalField cd = part.getHeader().getField(MIME.CONTENT_DISPOSITION);
				writeField(cd, this.charset, out);
				String filename = part.getBody().getFilename();
				if (filename != null) {
					MinimalField ct = part.getHeader().getField(MIME.CONTENT_TYPE);
					writeField(ct, this.charset, out);
				}
				break;
			}
			writeBytes(CR_LF, out);

			if (writeContent) {
				part.getBody().writeTo(out);
			}
			writeBytes(CR_LF, out);
		}
		writeBytes(TWO_DASHES, out);
		writeBytes(boundary, out);
		writeBytes(TWO_DASHES, out);
		writeBytes(CR_LF, out);
	}

	/**
	 * Writes out the content in the multipart/form encoding. This method
	 * produces slightly different formatting depending on its compatibility
	 * mode.
	 *
	 * @see #getMode()
	 */
	public void writeTo(final OutputStream out) throws IOException {
		doWriteTo(this.mode, out, true);
	}

	/**
	 * Determines the total length of the multipart content (content length of
	 * individual parts plus that of extra elements required to delimit the
	 * parts from one another). If any of the @{link BodyPart}s contained in
	 * this object is of a streaming entity of unknown length the total length
	 * is also unknown.
	 *

	 * This method buffers only a small amount of data in order to determine the
	 * total length of the entire entity. The content of individual parts is not
	 * buffered.
	 *
	 * @return total length of the multipart entity if known, -1
	 *         otherwise.
	 */
	public long getTotalLength() {
		long contentLen = 0;
		for (FormBodyPart part : this.parts) {
			ContentBody body = part.getBody();
			long len = body.getContentLength();
			if (len >= 0) {
				contentLen += len;
			}
			else {
				return -1;
			}
		}
		ByteArrayOutputStream out = new ByteArrayOutputStream();
		try {
			doWriteTo(this.mode, out, false);
			byte[] extra = out.toByteArray();
			return contentLen + extra.length;
		}
		catch (IOException ex) {
			// Should never happen
			return -1;
		}
	}

}


/*
 * Copyright (c) 2012 Socialize Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package com.socialize.api.action.view;

import android.location.Location;
import com.socialize.api.SocializeApi;
import com.socialize.api.SocializeSession;
import com.socialize.entity.Entity;
import com.socialize.entity.View;
import com.socialize.listener.view.ViewListener;
import com.socialize.provider.SocializeProvider;

import java.util.ArrayList;
import java.util.List;

/**
 * @author Jason Polites
 */
public class SocializeViewSystem extends SocializeApi<View, SocializeProvider<View>> implements ViewSystem {

	public SocializeViewSystem(SocializeProvider<View> provider) {
		super(provider);
	}

	/* (non-Javadoc)
	 * @see com.socialize.api.action.ViewSystem#addView(com.socialize.api.SocializeSession, com.socialize.entity.Entity, android.location.Location, com.socialize.listener.view.ViewListener)
	 */
	@Override
	public void addView(SocializeSession session, Entity entity, Location location, ViewListener listener) {
		View c = new View();
		c.setEntitySafe(entity);

		setLocation(c);

		List<View> list = new ArrayList<View>(1);
		list.add(c);

		postAsync(session, ENDPOINT, list, listener);
	}

	@Override
	public void getView(SocializeSession session, long id, ViewListener listener) {
		getAsync(session, ENDPOINT, String.valueOf(id), listener);
	}
}


/*
 * Copyright 2014 the original author or authors.
 *
 * 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.github.sps.metrics.opentsdb;

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

import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.Socket;
import java.nio.charset.Charset;
import java.util.Collections;
import java.util.Set;

/**
 * Same as the {@link OpenTsdb} class in this package, but uses the
 * {@link Telnet}
 * format.
 *
 * This class can write to a {@link Socket} or a {@link Writer}.
 *
 * @author Adam Lugowski <adam.lugowski@turn.com>
 */
public class OpenTsdbTelnet extends OpenTsdb {
	private static final Logger logger = LoggerFactory.getLogger(OpenTsdbTelnet.class);

	protected interface WriterFactory {
		Writer getWriter() throws java.io.IOException;
	}

	protected static class SingleWriterFactory implements WriterFactory {
		private Writer writer;

		public SingleWriterFactory(Writer writer) {
			this.writer = writer;
		}

		@Override
		public Writer getWriter() {
			return writer;
		}
	}

	protected static class SocketWriterFactory implements WriterFactory {
		private String host = null;
		private int port = -1;

		public SocketWriterFactory(String host, int port) {
			this.host = host;
			this.port = port;
		}

		@Override
		public Writer getWriter() throws java.io.IOException {
			Socket socket = new Socket(host, port, null, 0);
			Writer socketWriter = new OutputStreamWriter(socket.getOutputStream(), Charset.forName("US-ASCII"));
			return new BufferedWriter(socketWriter);
		}
	}

	/**
	 * Initiate a client {@link Builder} with the provided opentsdb server {@code host:port}.
	 *
	 * @param host is the hostname of the opentsdb server
	 * @param port is the port
	 * @return a {@link Builder}
	 */
	public static Builder forService(String host, int port) {
		return new Builder(new SocketWriterFactory(host, port));
	}

	/**
	 * Initiate a client {@link Builder} with a particular {@link Writer}.
	 *
	 * @param writer {@link Writer} to write metrics to. Will be closed when done.
	 * @return a {@link Builder}
	 */
	public static Builder forWriter(Writer writer) {
		return new Builder(new SingleWriterFactory(writer));
	}

	private WriterFactory writerFactory;

	public static class Builder {
		private WriterFactory writerFactory;

		private Builder(WriterFactory writerFactory) {
			this.writerFactory = writerFactory;
		}

		public OpenTsdbTelnet create() {
			return new OpenTsdbTelnet(writerFactory);
		}
	}

	private OpenTsdbTelnet(WriterFactory writerFactory) {
		this.writerFactory = writerFactory;
	}

	/**
	 * Send a metric to opentsdb
	 *
	 * @param metric
	 */
	@Override
	public void send(OpenTsdbMetric metric) {
		send(Collections.singleton(metric));
	}

	/**
	 * send a set of metrics to opentsdb
	 *
	 * @param metrics
	 */
	@Override
	public void send(Set<OpenTsdbMetric> metrics) {
		if (metrics.isEmpty())
			return;

		Writer writer = null;
		try {
			writer = this.writerFactory.getWriter();
			write(metrics, writer);
		} catch (Exception e) {
			if (logger.isDebugEnabled()) {
				logger.debug("Error writing codahale metrics", e);
			} else {
				logger.warn("Error writing codahale metrics: {}", e.getMessage());
			}
		} finally {
			if (writer != null) {
				try {
					writer.close();
				} catch (IOException e) {
					logger.error("Error while closing writer:", e);
				}
			}
		}
	}

	public void write(Set<OpenTsdbMetric> metrics, Writer writer) throws IOException {
		for (final OpenTsdbMetric metric : metrics) {
			writer.write(metric.toTelnetPutString());
		}
	}
}


/*
 * Copyright 2014 the original author or authors.
 *
 * 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.github.sps.metrics.opentsdb;

import org.glassfish.jersey.client.filter.EncodingFilter;
import org.glassfish.jersey.message.GZipEncoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.ws.rs.core.MediaType;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.Entity;
import javax.ws.rs.client.WebTarget;
import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.jackson.JacksonFeature;

/**
 * OpenTSDB 2.0 jersey based REST client.
 *
 * {@link HTTP API}
 *
 * @author Sean Scanlon <sean.scanlon@gmail.com>
 */
public class OpenTsdb {

    public static final int DEFAULT_BATCH_SIZE_LIMIT = 0;
    public static final int CONN_TIMEOUT_DEFAULT_MS = 5000;
    public static final int READ_TIMEOUT_DEFAULT_MS = 5000;
    private static final Logger logger = LoggerFactory.getLogger(OpenTsdb.class);

    /**
     * Initiate a client Builder with the provided base opentsdb server url.
     *
     * @param baseUrl
     * @return a {@link Builder}
     */
    public static Builder forService(String baseUrl) {
        return new Builder(baseUrl);
    }

    /**
     * create a client by providing the underlying WebResource
     *
     * @param apiResource
     * @return
     */
    public static OpenTsdb create(WebTarget apiResource) {
        return new OpenTsdb(apiResource);
    }

    private final WebTarget apiResource;
    private int batchSizeLimit = DEFAULT_BATCH_SIZE_LIMIT;

    public static class Builder {

        private Integer connectionTimeout = CONN_TIMEOUT_DEFAULT_MS;
        private Integer readTimeout = READ_TIMEOUT_DEFAULT_MS;
        private final String baseUrl;
        private boolean gzipEnabled = false;

        public Builder(String baseUrl) {
            this.baseUrl = baseUrl;
        }

        public Builder withConnectTimeout(Integer connectionTimeout) {
            this.connectionTimeout = connectionTimeout;
            return this;
        }

        public Builder withReadTimeout(Integer readTimeout) {
            this.readTimeout = readTimeout;
            return this;
        }

        public Builder withGzipEnabled(boolean gzipEnabled) {
            this.gzipEnabled = gzipEnabled;
            return this;
        }

        public OpenTsdb create() {
            return new OpenTsdb(baseUrl, connectionTimeout, readTimeout, gzipEnabled);
        }
    }

	/**
	 * For OpenTsdbTelnet
	 */
	protected OpenTsdb() {
		this.apiResource = null;
	}

    private OpenTsdb(WebTarget apiResource) {
        this.apiResource = apiResource;
    }

    private OpenTsdb(String baseURL, Integer connectionTimeout, Integer readTimeout, boolean gzipEnabled) {
        ClientBuilder builder = ClientBuilder.newBuilder()
                                             .register(JacksonFeature.class);
        if (gzipEnabled) {
            builder = builder.register(GZipEncoder.class)
                             .register(EncodingFilter.class)
                             .property(ClientProperties.USE_ENCODING, "gzip");
        }
        final Client client = builder.build();
        client.property(ClientProperties.CONNECT_TIMEOUT, connectionTimeout);
        client.property(ClientProperties.READ_TIMEOUT, readTimeout);

        this.apiResource = client.target(baseURL);
    }

    public void setBatchSizeLimit(int batchSizeLimit) {
        this.batchSizeLimit = batchSizeLimit;
    }

    /**
     * Send a metric to opentsdb
     *
     * @param metric
     */
    public void send(OpenTsdbMetric metric) {
        send(Collections.singleton(metric));
    }

    /**
     * send a set of metrics to opentsdb
     *
     * @param metrics
     */
    public void send(Set<OpenTsdbMetric> metrics) {
        // we set the patch size because of existing issue in opentsdb where large batch of metrics failed
        // see at https://groups.google.com/forum/#!topic/opentsdb/U-0ak_v8qu0
        // we recommend batch size of 5 - 10 will be safer
        // alternatively you can enable chunked request
        if (batchSizeLimit > 0 && metrics.size() > batchSizeLimit) {
            final Set<OpenTsdbMetric> smallMetrics = new HashSet<OpenTsdbMetric>();
            for (final OpenTsdbMetric metric : metrics) {
                smallMetrics.add(metric);
                if (smallMetrics.size() >= batchSizeLimit) {
                    sendHelper(smallMetrics);
                    smallMetrics.clear();
                }
            }
            sendHelper(smallMetrics);
        } else {
            sendHelper(metrics);
        }
    }

    private void sendHelper(Set<OpenTsdbMetric> metrics) {
        /*
         * might want to bind to a specific version of the API.
         * according to: http://opentsdb.net/docs/build/html/api_http/index.html#api-versioning
         * "if you do not supply an explicit version, ... the latest version will be used."
         * circle back on this if it's a problem.
         */
        if (!metrics.isEmpty()) {
            try {
                final Entity<?> entity = Entity.entity(metrics, MediaType.APPLICATION_JSON);
                apiResource.path("/api/put").request().post(entity);
            } catch (Exception ex) {
                logger.error("send to opentsdb endpoint failed", ex);
            }
        }
    }

}


/*
 * Copyright 2014 the original author or authors.
 *
 * 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.github.sps.metrics.opentsdb;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Scanner;
import java.lang.IllegalArgumentException;
import java.util.NoSuchElementException;

/**
 * Representation of a metric.
 *
 * @author Sean Scanlon <sean.scanlon@gmail.com>
 * @author Adam Lugowski <adam.lugowski@turn.com>
 *
 */
public class OpenTsdbMetric {

    private OpenTsdbMetric() {
    }

    /**
     * Convert a tag string into a tag map.
     *
     * @param tagString a space-delimited string of key-value pairs. For example, {@code "key1=value1 key_n=value_n"}
     * @return a tag {@link Map}
     * @throws IllegalArgumentException if the tag string is corrupted.
     */
    public static Map<String, String> parseTags(final String tagString) throws IllegalArgumentException {
        // delimit by whitespace or '='
        Scanner scanner = new Scanner(tagString).useDelimiter("\\s+|=");

        Map<String, String> tagMap = new HashMap<String, String>();
        try {
            while (scanner.hasNext()) {
                String tagName = scanner.next();
                String tagValue = scanner.next();
                tagMap.put(tagName, tagValue);
            }
        } catch (NoSuchElementException e) {
            // The tag string is corrupted.
            throw new IllegalArgumentException("Invalid tag string '" + tagString + "'");
        } finally {
            scanner.close();
        }

        return tagMap;
    }

    /**
     * Convert a tag map into a space-delimited string.
     *
     * @param tagMap
     * @return a space-delimited string of key-value pairs. For example, {@code "key1=value1 key_n=value_n"}
     */
    public static String formatTags(final Map<String, String> tagMap) {
        StringBuilder stringBuilder = new StringBuilder();
        String delimeter = "";

        for (Map.Entry<String, String> tag : tagMap.entrySet()) {
            stringBuilder.append(delimeter)
                    .append(sanitize(tag.getKey()))
                    .append("=")
                    .append(sanitize(tag.getValue()));
            delimeter = " ";
        }

        return stringBuilder.toString();
    }

    /**
     * Add TSDB tags to a CodaHale metric name.
     *
     * A CodaHale metric name is a single string, so there is no natural way to encode TSDB tags.
     * This function formats the tags into the metric name so they can later be parsed by the
     * Builder. The name is formatted such that it can be appended to create sub-metric names, as
     * happens with Meter and Histogram.
     *
     * @param name CodaHale metric name
     * @param tags A space-delimited string of TSDB key-value pair tags
     * @return A metric name encoded with tags.
     * @throws IllegalArgumentException if the tag string is invalid
     */
    public static String encodeTagsInName(final String name, final String tags) throws IllegalArgumentException {
        return encodeTagsInName(name, parseTags(tags));
    }

    /**
     * Add TSDB tags to a CodaHale metric name.
     *
     * A CodaHale metric name is a single string, so there is no natural way to encode TSDB tags.
     * This function formats the tags into the metric name so they can later be parsed by the
     * Builder. The name is formatted such that it can be appended to create sub-metric names, as
     * happens with Meter and Histogram.
     *
     * @param name CodaHale metric name
     * @param tags a {@link Map} of TSDB tags
     * @return A metric name encoded with tags.
     */
    public static String encodeTagsInName(final String name, final Map<String, String> tags) {
        return String.format("TAG(%s)%s", formatTags(tags), sanitize(name));
    }

    /**
     * Tests whether a name has been processed with {@code encodeTagsInName}.
     *
     * @param name a metric name
     * @return {@code true} if {@code name} has tags encoded, {@code false} otherwise.
     */
    public static boolean hasEncodedTagInName(final String name) {
        if (name == null)
            return false;

        return name.startsWith("TAG(");
    }

    /**
     * Call this function whenever a potentially tag-encoded name is prefixed.
     *
     * @param name a metric name with encoded tag strings that has been prefixed.
     * @return a fixed metric name
     */
    public static String fixEncodedTagsInNameAfterPrefix(final String name) {
        if (name == null)
            return name;

        int tagStart = name.indexOf("TAG(");

        if (tagStart == -1)
            return name; // no tags in this name

        if (tagStart == 0)
            return name; // tag string is already correct

        // extract the "TAG(...)" string from the middle of the name and put it at the front.
        int tagEnd = name.lastIndexOf(')');
        if (tagEnd == -1) {
            throw new IllegalArgumentException("Tag definition missing closing parenthesis for metric '" + name + "'");
        }

        String tagString = name.substring(tagStart, tagEnd+1);
        return tagString + name.substring(0, tagStart) + name.substring(tagEnd+1);
    }

    /**
     * Creates a Builder for a metric name.
     *
     * @param name name can contain either a pure CodaHale metric name, or a string returned by {@code encodeTagsInName}.
     *             If it's the latter, the tags are parsed out and passed to {@code withTags}.
     * @return a {@link Builder}
     */
    public static Builder named(String name) {
		/*
		A name can contain either a pure metric name, or a string returned by encodeTagsInName().
		If it's the latter, it looks like "TAG(tag1=value1 tag2=value2)metricname".
		 */
		if (!hasEncodedTagInName(name)) {
            return new Builder(name);
        }

        // parse out the tags
        int tagEnd = name.lastIndexOf(')');
        if (tagEnd == -1) {
            throw new IllegalArgumentException("Tag definition missing closing parenthesis for metric '" + name + "'");
        }

        String tagString = name.substring(4, tagEnd);
        name = name.substring(tagEnd+1);

        return new Builder(name).withTags(parseTags(tagString));
    }

    private String metric;

    private Long timestamp;

    private Object value;

    private Map<String, String> tags = new HashMap<String, String>();

    @Override
    public boolean equals(Object o) {

        if (o == this) {
            return true;
        }

        if (!(o instanceof OpenTsdbMetric)) {
            return false;
        }

        final OpenTsdbMetric rhs = (OpenTsdbMetric) o;

        return equals(metric, rhs.metric)
                && equals(timestamp, rhs.timestamp)
                && equals(value, rhs.value)
                && equals(tags, rhs.tags);
    }

    @Override
    public int hashCode() {
        return Arrays.hashCode(new Object[]{metric, timestamp, value, tags});
    }

    public static class Builder {

        private final OpenTsdbMetric metric;

        public Builder(String name) {
            this.metric = new OpenTsdbMetric();
            metric.metric = name;
        }

        public OpenTsdbMetric build() {
            return metric;
        }

        public Builder withValue(Object value) {
            metric.value = value;
            return this;
        }

        public Builder withTimestamp(Long timestamp) {
            metric.timestamp = timestamp;
            return this;
        }

        public Builder withTags(Map<String, String> tags) {
            if (tags != null) {
                metric.tags.putAll(tags);
            }
            return this;
        }
    }

    /**
     * Returns a JSON string version of this metric compatible with the HTTP API reporter.
     *
     * Example:
     *

     * {
     *     "metric": "sys.cpu.nice",
     *     "timestamp": 1346846400,
     *     "value": 18,
     *     "tags": {
     *         "host": "web01",
     *         "dc": "lga"
     *     }
     * }
     *

     * @return a JSON string version of this metric compatible with the HTTP API reporter.
     */
    @Override
    public String toString() {
        return this.getClass().getSimpleName()
                + "->metric: " + metric
                + ",value: " + value
                + ",timestamp: " + timestamp
                + ",tags: " + tags;
    }

    /**
     * Sanitizes a metric name, tag key, or tag value by removing characters not allowed by TSDB.
     *
     * Supported characters are {@code a-z A-Z 0-9 - _ . / }
     *
     * @param name a metric name, tag key, or tag value
     * @return {@code name} where unsupported characters are replaced with {@code "-"}.
     */
	public static String sanitize(String name) {
		return name.replaceAll("[^a-zA-Z0-9\\-\\_\\.\\/]", "-");
	}

    /**
     * Returns a put string version of this metric compatible with the telnet-style reporter.
     *
     * Format:
     *

     * put (metric-name) (timestamp) (value) (tags)
     *

     *
     * Example:
     *

     * put sys.cpu.nice 1346846400 18 host=web01 dc=lga
     *

     *
     * @return a string version of this metric compatible with the telnet reporter.
     */
	public String toTelnetPutString() {
		String tagString = formatTags(tags);

		return String.format("put %s %d %s %s%n", metric, timestamp, value, tagString);
	}

    public String getMetric() {
        return metric;
    }

    public Long getTimestamp() {
        return timestamp;
    }

    public Object getValue() {
        return value;
    }

    public Map<String, String> getTags() {
        return tags;
    }

    private boolean equals(Object a, Object b) {
        return (a == b) || (a != null && a.equals(b));
    }
}


/**
 * Copyright (c) 2013-2014 Santiago M. Mola <santi@mola.io>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
 * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 */
package io.mola.galimatias;

import com.ibm.icu.text.IDNA;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Domain extends Host {

    private static final long serialVersionUID = 2L;

    private final String domain;
    private final boolean unicode;

    private Domain(final String domain, final boolean unicode) {
        this.domain = domain;
        this.unicode = unicode;
    }

    public static Domain parseDomain(final String input) throws GalimatiasParseException {
        return parseDomain(input, false);
    }

    public List<String> labels() {
        return Arrays.asList(splitWorker(domain, "\u002E\u3002\uFF0E\uFF61", -1, true));
    }

    public static Domain parseDomain(final String input, final boolean unicode) throws GalimatiasParseException {
        return parseDomain(URLParsingSettings.create(), input, unicode);
    }

    public static Domain parseDomain(final URLParsingSettings settings, final String input, final boolean unicode) throws GalimatiasParseException {
        if (input == null) {
            throw new NullPointerException();
        }
        if (input.isEmpty()) {
            throw new GalimatiasParseException("input is empty");
        }

        final ErrorHandler errorHandler = settings.errorHandler();

        // WHATWG says: Let host be the result of running utf-8's decoder on the percent decoding of running utf-8 encode on input.
        String domain = URLUtils.percentDecode(input);

        String asciiDomain = URLUtils.domainToASCII(domain, errorHandler);

        for (int i = 0; i < asciiDomain.length(); i++) {
            char codePoint = asciiDomain.charAt(i);
            switch (codePoint) {
                case 0x0000:
                case 0x0009:
                case 0x000A:
                case 0x000D:
                case 0x0020:
                case '#':
                case '%':
                case '/':
                case ':':
                case '?':
                case '@':
                case '[':
                case '\\':
                case ']':
                    String message = "Illegal character in domain";
                    if (codePoint == ' ') {
                        message += ": space is not allowed";
                    } else if (codePoint == '\t') {
                        message += ": tab is not allowed";
                    } else if (codePoint == '\n') {
                        message += ": line break is not allowed";
                    } else if (codePoint == '\r') {
                        message += ": carriage return is not allowed";
                    } else {
                        message += ": \u201c" + new String(Character.toChars(codePoint)) + "\u201d is not allowed";
                    }
                    final GalimatiasParseException exception =
                            new GalimatiasParseException(message);
                    errorHandler.fatalError(exception);
                    throw exception;
            }
        }

        if (!unicode) {
            return new Domain(asciiDomain, unicode);
        }

        return new Domain(URLUtils.domainToUnicode(asciiDomain, errorHandler), unicode);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String toString() {
        return domain;
    }

    /**
     * Converts the domain to a Unicode representation suitable for human interpretation. It does IDNA conversion.
     *
     * @see Host#toHumanString()
     * @return unicode string
     */
    @Override
    public String toHumanString() {
        if (unicode) {
            return domain;
        }
        final IDNA.Info idnaInfo = new IDNA.Info();
        final StringBuilder idnaOutput = new StringBuilder();
        IDNA.getUTS46Instance(IDNA.DEFAULT).nameToUnicode(domain, idnaOutput, idnaInfo);
        return idnaOutput.toString();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Domain domain1 = (Domain) o;

        //if (unicode != domain1.unicode) return false;
        if (domain != null ? !domain.equals(domain1.domain) : domain1.domain != null) return false;

        return true;
    }

    @Override
    public int hashCode() {
        int result = domain != null ? domain.hashCode() : 0;
        //result = 31 * result + (unicode ? 1 : 0);
        return result;
    }

    /**
     * Imported from
     * https://github.com/apache/commons-lang/blob/690dc3c9c4cf8a1875d882ae09741c2e6342ad6b/src/main/java/org/apache/commons/lang3/StringUtils.java
     *
     * Performs the logic for the {@code split} and
     * {@code splitPreserveAllTokens} methods that return a maximum array
     * length.
     *
     * @param str  the String to parse, may be {@code null}
     * @param separatorChars the separate character
     * @param max  the maximum number of elements to include in the
     *  array. A zero or negative value implies no limit.
     * @param preserveAllTokens if {@code true}, adjacent separators are
     * treated as empty token separators; if {@code false}, adjacent
     * separators are treated as one separator.
     * @return an array of parsed Strings, {@code null} if null String input
     */
    private static String[] splitWorker(final String str, final String separatorChars, final int max, final boolean preserveAllTokens) {
        // Performance tuned for 2.0 (JDK1.4)
        // Direct code is quicker than StringTokenizer.
        // Also, StringTokenizer uses isSpace() not isWhitespace()

        //XXX: This never happens in Domain.parseDomain
        //if (str == null) {
        //    return null;
        //}
        final int len = str.length();
        //if (len == 0) {
        //    return EMPTY_STRING_ARRAY;
        //}

        final List<String> list = new ArrayList<String>();
        int sizePlus1 = 1;
        int i = 0, start = 0;
        boolean match = false;
        boolean lastMatch = false;
        if (separatorChars == null) {
            // Null separator means use whitespace
            while (i < len) {
                if (Character.isWhitespace(str.charAt(i))) {
                    if (match || preserveAllTokens) {
                        lastMatch = true;
                        if (sizePlus1++ == max) {
                            i = len;
                            lastMatch = false;
                        }
                        list.add(str.substring(start, i));
                        match = false;
                    }
                    start = ++i;
                    continue;
                }
                lastMatch = false;
                match = true;
                i++;
            }
        } else if (separatorChars.length() == 1) {
            // Optimise 1 character case
            final char sep = separatorChars.charAt(0);
            while (i < len) {
                if (str.charAt(i) == sep) {
                    if (match || preserveAllTokens) {
                        lastMatch = true;
                        if (sizePlus1++ == max) {
                            i = len;
                            lastMatch = false;
                        }
                        list.add(str.substring(start, i));
                        match = false;
                    }
                    start = ++i;
                    continue;
                }
                lastMatch = false;
                match = true;
                i++;
            }
        } else {
            // standard case
            while (i < len) {
                if (separatorChars.indexOf(str.charAt(i)) >= 0) {
                    if (match || preserveAllTokens) {
                        lastMatch = true;
                        if (sizePlus1++ == max) {
                            i = len;
                            lastMatch = false;
                        }
                        list.add(str.substring(start, i));
                        match = false;
                    }
                    start = ++i;
                    continue;
                }
                lastMatch = false;
                match = true;
                i++;
            }
        }
        if (match || preserveAllTokens && lastMatch) {
            list.add(str.substring(start, i));
        }
        return list.toArray(new String[list.size()]);
    }

}


/**
 * Copyright (c) 2013-2014 Santiago M. Mola <santi@mola.io>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
 * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 */
package io.mola.galimatias;

import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;

/**
 * Parses query strings with application/x-www-form-urlencoded rules.
 *
 * This class supersedes @{link java.net.URLEncoder}.
 *
 * @see https://url.spec.whatwg.org/#application/x-www-form-urlencoded-0
 */
public final class FormURLEncodedParser {

    private FormURLEncodedParser() {

    }

    private static final FormURLEncodedParser INSTANCE = new FormURLEncodedParser();

    @Deprecated
    public static FormURLEncodedParser getInstance() {
        return INSTANCE;
    }

    /**
     * Charset defaults to UTF-8.
     *
     * @see {@link #parse(String, java.nio.charset.Charset)}
     * @see application/x-www-form-urlencoded parser
     *
     * @param input
     */
    public static List<NameValue> parse(final String input) {
        return parse(input, Charset.forName("UTF-8"));
    }

    /**
     *
     * @see application/x-www-form-urlencoded parser
     *
     * @param input
     * @param charset Any charset. Note that the
     *                WHATWG Encoding Standard
     *                imposes restrictions on what encodings can and cannot be used. This method does not enforce
     *                such restrictions.
     */
    public static List<NameValue> parse(final String input, final Charset charset) {
        final boolean isIndex = false;

        if (input == null) {
            throw new NullPointerException("input");
        }
        if (charset == null) {
            throw new NullPointerException("charset");
        }

        //TODO: Encoding stuff is not here because we get Strings instead of bytes

        // 3. Let sequences be the result of splitting input on `&`.
        final String[] sequences = input.split("&");

        // 4. If the isindex flag is set and the first byte sequence in sequences does not contain
        //    a `=`, prepend `=` to the first byte sequence in sequences.
        if (isIndex) {
            if (sequences[0].isEmpty() || sequences[0].charAt(0) != '=') {
                sequences[0] = "=" + sequences[0];
            }
        }

        // 5. Let pairs be an empty list of name-value pairs where both name and value hold a byte sequence.
        final List<NameValue> pairs = new ArrayList<NameValue>();

        // 6. For each byte sequence bytes in sequences, run these substeps:
        for (final String bytes : sequences) {

            // 1. If bytes is the empty byte sequence, run these substeps for the next byte sequence.
            if (bytes.isEmpty()) {
                continue;
            }

            String name;
            String value;

            // 2. If bytes contains a `=`, then let name be the bytes from the start of bytes up to but excluding its
            //    first `=`, and let value be the bytes, if any, after the first `=` up to the end of bytes. If `=`
            //    is the first byte, then name will be the empty byte sequence. If it is the last, then value will be
            //    the empty byte sequence.
            final int equalsIndex = bytes.indexOf("=");
            if (equalsIndex != -1) {
                if (equalsIndex == 0) {
                    name = "";
                } else {
                    name = bytes.substring(0, equalsIndex);
                }
                if (equalsIndex == bytes.length() - 1) {
                    value = "";
                } else {
                    value = bytes.substring(equalsIndex + 1);
                }
            }

            // 3. Otherwise, let name have the value of bytes and let value be the empty byte sequence.
            else {
                name = bytes;
                value = "";
            }

            // 4. Replace any `+` in name and value with 0x20.
            name = name.replace('+', ' ');
            value = value.replace('+', ' ');

            // 5. If use _charset_ flag is set, name is `_charset_`, run these substeps:
            // TODO

            // 6. Add a pair consisting of name and value to pairs.
            pairs.add(new NameValue(name, value));

        }

        // 7. Let output be an empty list of name-value pairs where both name and value hold a string.
        // 8. For each name-value pair in pairs, append a name-value pair to output where the new name
        //    and value appended to output are the result of running encoding override's decoder on the percent
        //    decoding of the name and value from pairs, respectively.
        // TODO

        // 9. Return output.
        return pairs;
    }

    private static String serialize(ByteBuffer bytes) {
        if (bytes == null) {
            throw new NullPointerException("bytes");
        }
        return serialize(bytes.array(), bytes.arrayOffset(), bytes.limit());
    }

    /**
     * Implements the application/x-www-form-urlencoded byte serializer.
     */
    private static String serialize(final byte[] bytes, final int offset, final int length) {
        if (bytes == null) {
            throw new NullPointerException("bytes");
        }
        final StringBuilder sb = new StringBuilder(length);
        for (int i = offset; i < offset + length; i++) {
            final byte b = bytes[i];
            if (0x20 == b) {
                sb.append((char)0x20);
            } else if (0x2A == b || 0x2D == b || 0x2E == b || (0x30 <= b && b <= 0x39)
                    || (0x41 <= b && b <= 0x5A) || 0x5F == b || (0x61 <= b && b <= 0x7A)) {
                sb.appendCodePoint(b);
            } else {
                URLUtils.percentEncode(b, sb);
            }
        }
        return sb.toString();
    }

    /**
     * Replacement for {@link java.net.URLEncoder#encode(String, String)}.
     *
     * Uses UTF-8 as default charset.
     *
     * @see {@link #encode(java.util.List, java.nio.charset.Charset)}
     *
     * @param input
     * @return
     */
    public static String encode(final String input) {
        // 1. If encoding override is not given, set it to utf-8.
        return encode(input, Charset.forName("UTF-8"));
    }

    /**
     * Replacement for {@link java.net.URLEncoder#encode(String, String)}.
     *
     * @see {@link #encode(java.util.List, java.nio.charset.Charset)}
     *
     * @param input
     * @param charset
     * @return
     */
    public static String encode(final String input, final Charset charset) {
        return encode(parse(input), charset);
    }

    /**
     * Implements the application/x-www-form-urlencoded serializer.
     *
     * Uses UTF-8 as default charset.
     *
     * @see {@link #encode(java.util.List, java.nio.charset.Charset)}
     *
     * @param input
     * @return
     */
    public static String encode(final List<NameValue> input) {
        // 1. If encoding override is not given, set it to utf-8.
        return encode(input, Charset.forName("UTF-8"));
    }

    /**
     * Implements the application/x-www-form-urlencoded serializer.
     *
     * @param input
     * @param charset
     * @return
     */
    public static String encode(final List<NameValue> input, final Charset charset) {
        if (input == null) {
            throw new NullPointerException("input");
        }
        if (charset == null) {
            throw new NullPointerException("charset");
        }

        // 2. Let output be the empty string.
        final StringBuilder sb = new StringBuilder();

        // 3. For each pair in pairs, run these substeps:
        for (int i = 0; i < input.size(); i++) {
            final NameValue pair = input.get(i);

            // 1. Let outputPair be a copy of pair.
            // N/A

            // 2. Replace outputPair's name and value with the result of running encode on them using
            //    encoding override, respectively.
            // 3. Replace outputPair's name and value with their serialization.
            final String outputName = serialize(charset.encode(pair.name()));
            final String outputValue = serialize(charset.encode(pair.value()));

            // 4. If pair is not the first pair in pairs, append "&" to output.
            if (i != 0) {
                sb.append('&');
            }

            // 5. Append outputPair's name, followed by "=", followed by outputPair's value to output.
            sb.append(outputName).append('=').append(outputValue);
        }

        // 4. Return output.
        return sb.toString();
    }

}


/**
 * Copyright (c) 2013-2014 Santiago M. Mola <santi@mola.io>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
 * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 */
package io.mola.galimatias;

import com.ibm.icu.text.IDNA;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.List;

/**
 * Utils for parsing and serializing URLs.
 *
 * Not to be confused with the URLUtils from the WHATWG URL spec.
 *
 */
public final class URLUtils {

    public static final Charset UTF_8 = Charset.forName("UTF-8");

    private static final IDNA idna = IDNA.getUTS46Instance(IDNA.DEFAULT);

    private URLUtils() {

    }

    /**
     * Percent-decodes a string.
     *
     * Percent-encoded bytes are assumed to represent UTF-8 characters.
     *
     * @see WHATWG URL Standard: Percent-encoded bytes
     *
     * @param input
     * @return
     */
    public static String percentDecode(final String input) {
        if (input.isEmpty()) {
            return input;
        }
        try {
            final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
            int idx = 0;
            while (idx < input.length()) {

                boolean isEOF = idx >= input.length();
                int c = (isEOF)? 0x00 : input.codePointAt(idx);

                while (!isEOF && c != '%') {
                    if (c <= 0x7F) { // String.getBytes is slow, so do not perform encoding
                                     // if not needed
                        bytes.write((byte) c);
                        idx++;
                    } else {
                        bytes.write(new String(Character.toChars(c)).getBytes(UTF_8));
                        idx += Character.charCount(c);
                    }
                    isEOF = idx >= input.length();
                    c = (isEOF)? 0x00 : input.codePointAt(idx);
                }

                if (c == '%' && (input.length() <= idx + 2 ||
                        !isASCIIHexDigit(input.charAt(idx + 1)) ||
                        !isASCIIHexDigit(input.charAt(idx + 2)))) {
                    if (c <= 0x7F) { // String.getBytes is slow, so do not perform encoding
                        // if not needed
                        bytes.write((byte) c);
                        idx++;
                    } else {
                        bytes.write(new String(Character.toChars(c)).getBytes(UTF_8));
                        idx += Character.charCount(c);
                    }
                } else {
                    while (c == '%' && input.length() > idx + 2 &&
                            isASCIIHexDigit(input.charAt(idx + 1)) &&
                            isASCIIHexDigit(input.charAt(idx + 2))) {
                        bytes.write(hexToInt(input.charAt(idx + 1), input.charAt(idx + 2)));
                        idx += 3;
                        c = (input.length() <= idx)? 0x00 : input.codePointAt(idx);
                    }
                }
            }
            return new String(bytes.toByteArray(), UTF_8);
        } catch (IOException ex) {
            throw new RuntimeException(ex);
        }
    }

    /**
     * Converts a domain to its ASCII representation. Uses the the name to ASCII
     * as specified in the IDNA standard.
     *
     * @see WHATWG URL Standard - IDNA Section
     *
     * @param domain
     * @return
     */
    public static String domainToASCII(final String domain) throws GalimatiasParseException {
        return domainToASCII(domain, DefaultErrorHandler.getInstance());
    }

    static String domainToASCII(final String domain, final ErrorHandler errorHandler) throws GalimatiasParseException {
        final IDNA.Info idnaInfo = new IDNA.Info();
        final StringBuilder idnaOutput = new StringBuilder();
        idna.nameToASCII(domain, idnaOutput, idnaInfo);
        processIdnaInfo(errorHandler, idnaInfo, false);
        return idnaOutput.toString();
    }

    /**
     * Converts a domain to its Unicode representation. Uses the the name to Unicode
     * as specified in the IDNA standard.
     *
     * @see WHATWG URL Standard - IDNA Section
     *
     * @param asciiDomain
     * @return
     */
    public static String domainToUnicode(final String asciiDomain) throws GalimatiasParseException {
        return domainToUnicode(asciiDomain, DefaultErrorHandler.getInstance());
    }

    static String domainToUnicode(final String asciiDomain, final ErrorHandler errorHandler) throws GalimatiasParseException {
        final IDNA.Info unicodeIdnaInfo = new IDNA.Info();
        final StringBuilder unicodeIdnaOutput = new StringBuilder();
        idna.nameToUnicode(asciiDomain, unicodeIdnaOutput, unicodeIdnaInfo);
        processIdnaInfo(errorHandler, unicodeIdnaInfo, false);
        return unicodeIdnaOutput.toString();
    }

    private static void processIdnaInfo(final ErrorHandler errorHandler,
            final IDNA.Info idnaInfo, final boolean checkHyphens)
            throws GalimatiasParseException {
        for (IDNA.Error error : idnaInfo.getErrors()) {
            String msg;
            switch (error) {
                case BIDI:
                    msg = "A label does not meet the IDNA BiDi requirements (for right-to-left characters).";
                    break;
                case CONTEXTJ:
                    msg = "A label does not meet the IDNA CONTEXTJ requirements.";
                    break;
                case CONTEXTO_DIGITS:
                    msg = "A label does not meet the IDNA CONTEXTO requirements for digits.";
                    break;
                case CONTEXTO_PUNCTUATION:
                    msg = "A label does not meet the IDNA CONTEXTO requirements for punctuation characters.";
                    break;
                case DISALLOWED:
                    msg = "A label or domain name contains disallowed characters.";
                    break;
                case DOMAIN_NAME_TOO_LONG:
                    msg = "A domain name is longer than 255 bytes in its storage form.";
                    break;
                case EMPTY_LABEL:
                    msg = "A non-final domain name label (or the whole domain name) is empty.";
                    break;
                case HYPHEN_3_4:
                    if (!checkHyphens) {
                        return;
                    }
                    msg = "A label contains hyphen-minus ('-') in the third and fourth positions.";
                    break;
                case INVALID_ACE_LABEL:
                    msg = "An ACE label does not contain a valid label string.";
                    break;
                case LABEL_HAS_DOT:
                    msg = "A label contains a dot=full stop.";
                    break;
                case LABEL_TOO_LONG:
                    msg = "A domain name label is longer than 63 bytes.";
                    break;
                case LEADING_COMBINING_MARK:
                    msg = "A label starts with a combining mark.";
                    break;
                case LEADING_HYPHEN:
                    if (!checkHyphens) {
                        return;
                    }
                    msg = "A label starts with a hyphen-minus ('-').";
                    break;
                case PUNYCODE:
                    msg = "A label starts with \"xn--\" but does not contain valid Punycode.";
                    break;
                case TRAILING_HYPHEN:
                    if (!checkHyphens) {
                        return;
                    }
                    msg = "A label ends with a hyphen-minus ('-').";
                    break;
                default:
                    msg = "IDNA error.";
                    break;
            }
            final GalimatiasParseException exception = new GalimatiasParseException(msg);
            errorHandler.fatalError(exception);
            throw exception;
        }
    }

    public static boolean isASCIIHexDigit(final int c) {
        return (c >= 0x0041 && c <= 0x0046) || (c >= 0x0061 && c <= 0x0066) || isASCIIDigit(c);
    }

    public static boolean isASCIIDigit(final int c) {
        return c >= 0x0030 && c <= 0x0039;
    }

    public static boolean isASCIIAlphaUppercase(final int c) {
        return c >= 0x0061 && c <= 0x007A;
    }

    public static boolean isASCIIAlphaLowercase(final int c) {
        return c >= 0x0041 && c <= 0x005A;
    }

    public static boolean isASCIIAlpha(final int c) {
        return isASCIIAlphaLowercase(c) || isASCIIAlphaUppercase(c);
    }

    public static boolean isASCIIAlphanumeric(final int c) {
        return isASCIIAlpha(c) || isASCIIDigit(c);
    }

    public static boolean isURLCodePoint(final int c) {
        return
                isASCIIAlphanumeric(c) ||
                        c == '!' ||
                        c == '$' ||
                        c == '&' ||
                        c == '\'' ||
                        c == '(' ||
                        c == ')' ||
                        c == '*' ||
                        c == '+' ||
                        c == ',' ||
                        c == '-' ||
                        c == '.' ||
                        c == '/' ||
                        c == ':' ||
                        c == ';' ||
                        c == '=' ||
                        c == '?' ||
                        c == '@' ||
                        c == '_' ||
                        c == '~' ||
                        (c >= 0x00A0 && c <= 0xD7FF) ||
                        (c >= 0xE000 && c <= 0xFDCF) ||
                        (c >= 0xFDF0 && c <= 0xFFEF) ||
                        (c >= 0x10000 && c <= 0x1FFFD) ||
                        (c >= 0x20000 && c <= 0x2FFFD) ||
                        (c >= 0x30000 && c <= 0x3FFFD) ||
                        (c >= 0x40000 && c <= 0x4FFFD) ||
                        (c >= 0x50000 && c <= 0x5FFFD) ||
                        (c >= 0x60000 && c <= 0x6FFFD) ||
                        (c >= 0x70000 && c <= 0x7FFFD) ||
                        (c >= 0x80000 && c <= 0x8FFFD) ||
                        (c >= 0x90000 && c <= 0x9FFFD) ||
                        (c >= 0xA0000 && c <= 0xAFFFD) ||
                        (c >= 0xB0000 && c <= 0xBFFFD) ||
                        (c >= 0xC0000 && c <= 0xCFFFD) ||
                        (c >= 0xD0000 && c <= 0xDFFFD) ||
                        (c >= 0xE0000 && c <= 0xEFFFD) ||
                        (c >= 0xF0000 && c <= 0xFFFFD) ||
                        (c >= 0x100000 && c <= 0x10FFFD);
    }

    private static final char[] _hex = "0123456789ABCDEF".toCharArray();
    static void byteToHex(final byte b, StringBuilder buffer) {
        int i = b & 0xFF;
        buffer.append(_hex[i >>> 4]);
        buffer.append(_hex[i & 0x0F]);
    }

    public static int hexToInt(final char c1, final char c2) {
        //TODO: Some micro-optimization here?
        return Integer.parseInt(new String(new char[]{c1, c2}), 16);
    }

    public static void percentEncode(final byte b, StringBuilder buffer) {
        buffer.append('%');
        byteToHex(b, buffer);
    }

    private static final List<String> RELATIVE_SCHEMES = Arrays.asList(
            "ftp", "file", "gopher", "http", "https", "ws", "wss"
    );

    /**
     * Returns true if the schema is a known relative schema
     * (ftp, file, gopher, http, https, ws, wss).
     *
     * @param scheme
     * @return
     */
    public static boolean isRelativeScheme(final String scheme) {
        return RELATIVE_SCHEMES.contains(scheme);
    }

    /**
     * Gets the default port for a given schema. That is:
     *
     *

     *
ftp - 21

     *
file - null

     *
gopher - 70

     *
http - 80

     *
https - 443

     *
ws - 80

     *
wss - 433

     *

     *
     * @param scheme
     * @return
     */
    public static String getDefaultPortForScheme(final String scheme) {
        if ("ftp".equals(scheme)) {
            return "21";
        }
        if ("file".equals(scheme)) {
            return null;
        }
        if ("gopher".equals(scheme)) {
            return "70";
        }
        if ("http".equals(scheme)) {
            return "80";
        }
        if ("https".equals(scheme)) {
            return "443";
        }
        if ("ws".equals(scheme)) {
            return "80";
        }
        if ("wss".equals(scheme)) {
            return "443";
        }
        return null;
    }

}