/* 
 * Copyright 2015 by AVM GmbH <info@avm.de>
 *
 * This software contains free software; you can redistribute it and/or modify 
 * it under the terms of the GNU General Public License ("License") as 
 * published by the Free Software Foundation  (version 3 of the License). 
 * This software is distributed in the hope that it will be useful, but 
 * WITHOUT ANY WARRANTY; without even the implied warranty of 
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the copy of the 
 * License you received along with this software for more details.
 */

package de.avm.android.fritzapp.com.discovery;

import java.io.IOException;
import java.net.InetAddress;
import java.net.URI;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

import de.avm.android.fritzapp.com.ComSettingsChecker;
import de.avm.android.fritzapp.util.InetAddressHelper;
import de.avm.android.fritzapp.util.NetworkInterfaceHelper;
import de.avm.android.tr064.Tr064Boxinfo;
import de.avm.android.tr064.discovery.FritzBoxDiscovery;
import de.avm.fundamentals.logger.FileLog;

import android.content.Context;
import android.os.Handler;

/**
 * Discovers FRITZ!Boxes
 */
public class BoxFinder
{
	private static final String TAG = "BoxFinder";
	
	private static final int MAX_THREADS = 7;
	private static final int TIMEOUT_SSDP = FritzBoxDiscovery.DEFAULT_TIMEOUT;	// SSDP search
	private static final int TIMEOUT_HTTP = 2000;	// single HTTP connection
	private static final int TIMEOUT_MANUAL = 10000;	// one manual search (>TIMEOUT_SSDP!)
	private static final String[] DEFAULT_MANUAL_SEARCHES =
		new String[] { "fritz.box" };
	
	public interface OnSearchDoneListener
	{
		void onSearchDone();
	}

	private Context mContext = null;
	private final BoxInfoList mBoxes = new BoxInfoList();
	private boolean mHasSearched = false;

	// search
	private final LinkedList<OnSearchDoneListener> mSearchDoneListeners = new LinkedList<>();
	private ExecutorService mThreadPool = null;
	private final PendingList mPending = new PendingList();
	private Timer mSearchTimeout = null; 
	private Handler mHandler = new Handler();

	static
	{
		FritzBoxDiscovery.setOnGetAddressByNameListener(
				new FritzBoxDiscovery.OnGetAddressByNameListener()
		{
			public InetAddress getAddressByNameListener(String host)
					throws UnknownHostException
			{
				return InetAddressHelper.getByName(host);
			}
		});
	}
	
	public boolean isInitialized()
	{
		return (mContext != null);
	}
	
	public boolean hasSearched()
	{
		return mHasSearched;
	}
	
	public BoxInfoList getBoxes()
	{
		return mBoxes;
	}

	/**
	 * Initializes the instance
	 * (call from main thread)
	 * 
	 * @param context
	 * 		application's context
	 */
	public void initialize(Context context)
	{
		if (mContext != context)
		{
			mContext = context;
			mBoxes.init(context);
		}
	}

	/**
	 * @return
	 * 		true if search is in progress 
	 */
	public boolean isSearching()
	{
		synchronized(mSearchDoneListeners)
		{
			return mThreadPool != null;
		}		
	}
	
	/**
	 * Starts searching, listener will be called when done
	 * (Only one search is running at a time! Calling while searching,
	 * results in adding listener and optionalHosts to this an in
	 * restarting timeout)
	 * 
	 * @param listener
	 * 		listener called when search is done (called in thread
	 * 		this BoxFinder object is created from)
	 * @param optionalHosts
	 * 		hosts (name or IP address) to include in search, may be null or empty 
	 */
	public void startSearch(OnSearchDoneListener listener,
			String[] optionalHosts)
	{
		synchronized(mSearchDoneListeners)
		{
			if ((listener != null) &&
					!mSearchDoneListeners.contains(listener))
				mSearchDoneListeners.add(listener);

			LinkedList<String> manualDiscoveries = new LinkedList<>();
			if (optionalHosts != null)
				for (String host : optionalHosts)
					if (!manualDiscoveries.contains(host))
						manualDiscoveries.add(host);
			
			if (mThreadPool == null)
			{
				mBoxes.reset();
				mHasSearched = false;
				mThreadPool = Executors.newFixedThreadPool(MAX_THREADS);
				mPending.clear();

				// what we are searching for by default
				for (String host : DEFAULT_MANUAL_SEARCHES)
					if (!manualDiscoveries.contains(host))
						manualDiscoveries.add(host);
				
				// add known IPs
				synchronized(mBoxes)
				{
					for(BoxInfo info : mBoxes)
					{
						URL location = info.getLocation(); 
						if ((location != null) &&
								!manualDiscoveries.contains(location.getHost()))
							manualDiscoveries.add(location.getHost());
					}
				}
				
				// start SSDP discovery
				mPending.add(mThreadPool.submit(
						new SsdpDiscoveryRunnable(mThreadPool)));
			}
			
			// start manual discovery
			if (manualDiscoveries.size() > 0)
			{
				synchronized(mPending)
				{
					mPending.add(mThreadPool.submit(
							new ManualDiscoveryStarter(mThreadPool, manualDiscoveries)));
				}
			}
			else startTimeout();
		}
	}

	/**
	 * Cancels a running search
	 * (all pending listeners are called before returning,
	 * the search threads could still be running for a while,
	 * the box list might be incomplete)
	 * 
	 * @return
	 * 		true, if search was running
	 */
	public boolean cancelSearch()
	{
		synchronized(mSearchDoneListeners)
		{
			if (isSearching())
			{
				mPending.clear();
				onSearchDone();
				return true;
			}
		}
		return false;
	}
	
	/**
	 * Called when one of the discoveries has finished 
	 */
	@SuppressWarnings("unchecked")
	private void onSearchDone()
	{
		// called from search thread which is about to finish
		// or while mSearchDoneListeners is locked
		// must be handled later on
		mHandler.postDelayed(new Runnable()
		{
			public void run()
			{
				LinkedList<OnSearchDoneListener> listeners = null; 
				synchronized(mSearchDoneListeners)
				{
					if (isSearching() && mPending.isDone())
					{
						// finished all
						stopTimeout();
						mThreadPool.shutdown();
						mThreadPool = null;
						mPending.clear();
						mHasSearched = true;
						if (mSearchDoneListeners.size() > 0)
						{
							listeners = (LinkedList<OnSearchDoneListener>)
									mSearchDoneListeners.clone();
							mSearchDoneListeners.clear();
						}
					}
					else return; // not searching or not finished yet
				}
				synchronized(mBoxes)
				{
					// save changed data of known devices
					if (mContext != null)
						mBoxes.save(mContext);

					FileLog.d(TAG, String.format("discovered %d usable boxes",
                            mBoxes.getCountOfUsables()));
					for (BoxInfo info : mBoxes)
						if (info.isAvailable())
						{
							try { FileLog.d(TAG, String.format("  %s: %s %sTR-064",
									info.getLocation().getHost(), info.getUdn(),
									(info.isTr64())?"":"no "));}
							catch (Exception ignored) { }
						}
				}
				if (listeners != null)
					for (OnSearchDoneListener listener : listeners)
						listener.onSearchDone();
			}
			
		}, 100);
	}

	private void startTimeout()
	{
		synchronized(mSearchDoneListeners)
		{
			if (mSearchTimeout != null) mSearchTimeout.cancel();

			int timeouts = (mPending.pendingCount() + (MAX_THREADS - 1)) / MAX_THREADS;
			if (timeouts < 1) timeouts = 1;
			mSearchTimeout = new Timer();
			mSearchTimeout.schedule(new TimerTask()
			{
				public void run()
				{
					cancelSearch();
				}
				
			}, TIMEOUT_MANUAL * timeouts);
		}		
	}
	
	private void stopTimeout()
	{
		synchronized(mSearchDoneListeners)
		{
			if (mSearchTimeout != null)
			{
				mSearchTimeout.cancel();
				mSearchTimeout = null;
			}
		}		
	}
	
	// list of pending searches
	@SuppressWarnings("serial")
	private class PendingList
			extends LinkedList<Future<?>>
	{
		public synchronized boolean isDone()
		{
			for(Future<?> future : this)
				if (!future.isCancelled() && !future.isDone())
					return false;
			return true;
		}
		
		public synchronized int pendingCount()
		{
			int pending = 0;
			for(Future<?> future : this)
				if (!future.isCancelled() && !future.isDone())
					pending++;
			return pending;
		}
	}
	
	// SSDP discovery thread runnable
	private class SsdpDiscoveryRunnable implements Runnable
	{
		private Object mRefThreadPool;
		
		public SsdpDiscoveryRunnable(Object ref)
		{
			mRefThreadPool = ref;
		}
		
		public void run()
		{
			try
			{
				if (mContext == null)
					throw new IllegalStateException("BoxFinder instance has not been initalized!");

				// get addresses of interfaces
				ArrayList<InetAddress> interfaceAddresses = NetworkInterfaceHelper
						.getInetAddresses(mContext);

				FritzBoxDiscovery.discover(mContext, TIMEOUT_SSDP, interfaceAddresses,
						new FritzBoxDiscovery.OnDiscoveredDeviceListener()
						{
							public void onDiscoveredDevice(boolean isTR064,
									String udn, URL location, String server)
							{
								synchronized(mSearchDoneListeners)
								{
									if (mRefThreadPool == BoxFinder.this.mThreadPool)
									{
										synchronized(BoxFinder.this.mBoxes)
										{
											BoxFinder.this.mBoxes.foundBox(isTR064,
													udn, location, server);
										}
									}
								}
							}
						});
			}
			catch (IOException e)
			{
				FileLog.w(TAG, "SsdpDiscoveryRunnable: failed to dicover devices.", e);
			}

			boolean inSearch;
			synchronized(mSearchDoneListeners)
			{
				inSearch = (mRefThreadPool == BoxFinder.this.mThreadPool);
			}
			if (inSearch) onSearchDone();
		}
	}

	// prepare and start manual discovery thread runnable
	// InetAddressHelper.getByName not allowed in main thread, so begin with this preparation thread
	private class ManualDiscoveryStarter implements Runnable
	{
		private Object mRefThreadPool;
		private LinkedList<String> mHosts;
		
		public ManualDiscoveryStarter(Object ref, LinkedList<String> hosts)
		{
			mRefThreadPool = ref;
			mHosts = hosts;
		}

		public void run()
		{
			// prepare list
			LinkedList<String> hosts = new LinkedList<>();
			for (String host : mHosts)
			{
				try
				{
					host = InetAddressHelper.getByName(host).getHostAddress();
					if (!hosts.contains(host)) hosts.add(host);
				}
				catch (UnknownHostException ignored) { }
			}

			// start searches
			synchronized(mSearchDoneListeners)
			{
				if (mRefThreadPool == BoxFinder.this.mThreadPool)
				{
					if (hosts.size() > 0)
					{
						synchronized(mPending)
						{
							for(String disc : hosts)
							{
								mPending.add(mThreadPool.submit(
										new ManualDiscoveryRunnable(mThreadPool, disc)));
							}
						}
					}
					startTimeout();
				}
			}
		}
	}

	// manual discovery thread runnable
	private class ManualDiscoveryRunnable implements Runnable
	{
		private Object mRefThreadPool;
		private String mHost; 
		
		public ManualDiscoveryRunnable(Object ref, String host)
		{
			mRefThreadPool = ref;
			mHost = host;
		}
		
		public void run()
		{
			URI uri = null;
			try
			{
				// try to download description
				uri = Tr064Boxinfo.createDefaultUri(mHost);
				Tr064Boxinfo tr64Boxinfo = Tr064Boxinfo.createInstance(uri,
						TIMEOUT_HTTP, ComSettingsChecker.PARSE_TR064_CAPABILITIES);
				synchronized(mSearchDoneListeners)
				{
					if (mRefThreadPool == BoxFinder.this.mThreadPool)
					{
						synchronized(BoxFinder.this.mBoxes)
						{
							BoxFinder.this.mBoxes.foundBox(tr64Boxinfo);
						}
					}
				}
			}
			catch (Exception e)
			{
				FileLog.w(TAG, "ManualDiscoveryRunnable: manual discovery failed on " +
						((uri == null) ? "(null)" : uri.getHost()), e);
			}
			finally
			{
				boolean inSearch;
				synchronized(mSearchDoneListeners)
				{
					inSearch = (mRefThreadPool == BoxFinder.this.mThreadPool);
				}
				if (inSearch) onSearchDone();
			}
		}
	}
}
