/* 
 * 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.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedList;
import java.util.ListIterator;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

import org.json.JSONArray;
import org.json.JSONObject;
import org.json.JSONTokener;

import de.avm.android.security.CipherWrapper;
import de.avm.android.tr064.Tr064Boxinfo;
import de.avm.fundamentals.logger.FileLog;

import android.content.Context;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.preference.PreferenceManager;
import android.text.TextUtils;

/**
 * List of boxes (known from preferences and/or discovered)
 */
@SuppressWarnings("serial")
public class BoxInfoList extends LinkedList<BoxInfo>
{
	private static final String TAG = "BoxInfoList";
	
	public static final String PREF_BOXINFOLIST = "boxes";
	public static final String PREF_BOXINFOLIST_CIPHER = "binfo";
	public static final String PREF_BOXINFOLIST_CIPHERTAG = "binfo-tag";
	private static final int PREF_BOXINFO_MAXITEMS = 10;
	public static final String MANUFACTURER = "AVM";
	public static final String KEYSTORE_KEYPAIR_ALIAS = "fon";

	private static SharedPreferences getPrefs(Context context)
	{
		return PreferenceManager.getDefaultSharedPreferences(context);
	}
	
	public synchronized boolean hasAvailables()
	{
		for(BoxInfo info : this)
			if (info.isAvailable()) return true;
		return false;
	}

	/**
	 * Checks if there are items with preferences
	 * @return true if there are items with preferences
	 */
	public synchronized boolean hasPreferences()
	{
		for(BoxInfo info : this)
			if (info.hasPreferences()) return true;
		return false;
	}
	
	public synchronized int getCountOfUsables()
	{
		int count = 0;
		for(BoxInfo info : this)
			if (info.isAvailable() && info.isTr64() &&
					info.isAutoConnect())
				count++;
		return count;
	}
	
	/**
	 * Gets UDNs of all entries of available TR-064 devices
	 * @return the UDNs
	 */
	public synchronized String[] usablesToArray()
	{
		StringBuilder udns = new StringBuilder();
		for(BoxInfo info : this)
			if (info.isAvailable() && info.isTr64() &&
					info.isAutoConnect())
			{
				if (udns.length() > 0) udns.append('\n');
				udns.append(info.getUdn());
			}
		return udns.toString().split("\\n");
	}
	
	/**
	 * Gets most recently used of available boxes, if any
	 * 
	 * @return
	 * 		the item
	 */
	public synchronized BoxInfo getUsableMru()
	{
		BoxInfo boxInfo = null;
		for(BoxInfo info : this)
			if (info.hasPreferences() && info.isAvailable() &&
					info.isAutoConnect() &&
					((boxInfo == null) || (info.getMru() > boxInfo.getMru())))
				boxInfo = info;
		return boxInfo;
	}
	
	/**
	 * Gets Item by UDN
	 * 
	 * @param udn
	 *		the UDN
	 * @param availableOnly
	 *		true to get the object only if box is present 
	 * @return
	 * 		item or null
	 */
	public synchronized BoxInfo get(String udn, boolean availableOnly)
	{
		for(BoxInfo info : this)
			if (info.getUdn().equals(udn))
			{
				if (!availableOnly || info.isAvailable()) return info;
				break;
			}
		return null;
	}
	
	/**
	 * Initializes list (incl. loading from preferences).
	 * NEVER call it from synchronized block of this instance,
	 * this would dead-lock the calling thread!! 
	 * 
	 * @param context
	 * 		the context for accessing preferences
	 */
	public void init(final Context context)
	{
		final Semaphore running = new Semaphore(0);
		Thread thread = new Thread(new Runnable()
		{
			public void run()
			{
				synchronized(BoxInfoList.this)
				{
					running.release();
					// force to initialize key now (might need long at first time)
					CipherWrapper.createInstance(context, KEYSTORE_KEYPAIR_ALIAS);
					load(context);
				}
			}
		});
		Thread caller = Thread.currentThread();
		if (caller.getPriority() > Thread.MIN_PRIORITY)
			thread.setPriority(caller.getPriority() - 1);
		thread.start();
		try
		{
			// waiting for thread has been started
			running.tryAcquire(15, TimeUnit.SECONDS);
		}
		catch (InterruptedException ignored) {}
	}
	
	/**
	 * Loads list from preferences
	 * 
	 * @param context
	 * 		the context for accessing preferences
	 */
	private synchronized void load(Context context)
	{
		clear();
		try
		{
			String str = getBoxInfoListBlob(context);
			if (str.length() > 0)
			{
				Object json = new JSONTokener(str).nextValue();
				if (json.getClass().equals(JSONArray.class))
				{
					for(int ii = 0; ii < ((JSONArray)json).length(); ii++)
					{
						try
						{
							JSONObject jsonInfo = ((JSONArray)json)
									.getJSONObject(ii);
							add(new BoxInfo(jsonInfo));
						}
						catch (Exception e)
						{
                            FileLog.w(TAG, e.getMessage(), e);
						}
					}
				}
			}
		}
		catch (Exception e)
		{
			FileLog.e(TAG, "Cannot load from preferences.", e);
			clear();
		}
	}

	/**
	 * Saves list to preferences
	 * (only items with TR-064 )
	 * 
	 * @param context
	 * 		the context for accessing preferences
	 */
	public synchronized void save(Context context)
	{
		JSONArray json = new JSONArray();
		for(BoxInfo info : this)
		{
			JSONObject jsonInfo = info.getJson();
			if (jsonInfo != null) json.put(jsonInfo);
		}

		setBoxInfoListBlob(context, json.toString());
	}

	/**
	 * Removes all discovery info 
	 */
	public synchronized void reset()
	{
		if (size() > 0)
			for (ListIterator<BoxInfo> iterator = listIterator(0);
					iterator.hasNext();)
			{
				BoxInfo info = iterator.next();
				if (info.hasPreferences())
					info.reset();
				else
					iterator.remove();
			}
	}
	
	/**
	 * Sets MRU to current time
	 * 
	 * @param context
	 * 		the context for accessing preferences
	 * @param info
	 * 		update this box' MRU 
	 */
	public synchronized void updateMru(Context context, BoxInfo info)
	{
		if (contains(info) && info.isTr64())
		{
			if (!info.hasPreferences())
			{
				// keep only PREF_BOXINFO_MAXITEMS in preferences
				LinkedList<BoxInfo> list = new LinkedList<>();
				for(BoxInfo boxInfo : this)
					if (boxInfo.hasPreferences()) list.add(boxInfo);
				if (list.size() >= PREF_BOXINFO_MAXITEMS)
				{
					// get rid of oldest ones (if currently available on network, keep
					// it in list without MRU time)
					Collections.sort(list, new Comparator<BoxInfo>()
							{
								public int compare(BoxInfo object1, BoxInfo object2)
								{
									if (object1.getMru() < object2.getMru())
										return -1;
									if (object1.getMru() > object2.getMru())
										return 1;
									return 0;
								}
							});
					for (int ii = list.size() -1 ; ii >= PREF_BOXINFO_MAXITEMS - 1; ii--)
					{
						BoxInfo boxInfo = list.get(ii);
						if (boxInfo.isAvailable())
							boxInfo.updateMru(false); 
						else
							remove(boxInfo);
					}
				}
			}
			info.updateMru(true);
			save(context);
		}
	}
	
	private String getBoxInfoListBlob(Context context)
	{
		CipherWrapper.Type type = CipherWrapper.Type.NONE;
		
		String string = getPrefs(context)
				.getString(PREF_BOXINFOLIST_CIPHER, "");
		try
		{
			type = CipherWrapper.Type.valueOf(getPrefs(context)
					.getString(PREF_BOXINFOLIST_CIPHERTAG,
							CipherWrapper.Type.NONE.toString()));
		}
		catch(Exception ignored) {}

		String blob = null;
		if (!TextUtils.isEmpty(string) && (type != CipherWrapper.Type.NONE))
		{
			try
			{
				CipherWrapper cipher = CipherWrapper.createInstance(context, type,
						KEYSTORE_KEYPAIR_ALIAS);
				blob = cipher.decrypt(string);
			}
			catch(Exception e)
			{
				FileLog.e(TAG, "Failed to decrypt a preference.", e);
			}
		}

		if (blob == null)
			blob = getPrefs(context).getString(PREF_BOXINFOLIST, "");

		return blob;
	}
	
	private void setBoxInfoListBlob(Context context, String string)
	{
		String blob = null;
		CipherWrapper.Type type = CipherWrapper.Type.NONE;

        if (!TextUtils.isEmpty(string))
        {
            try
            {
                CipherWrapper cipher = CipherWrapper.createInstance(context,
                        KEYSTORE_KEYPAIR_ALIAS);
                blob = cipher.encrypt(string);
                type = cipher.getType();
            }
            catch (Exception e)
            {
                FileLog.e(TAG, "Failed to encrypt a preference.", e);
                if (CipherWrapper.getDesignatedType().ordinal() >=
                        CipherWrapper.Type.AKS.ordinal())
                {
                    try
                    {
                        CipherWrapper cipher = CipherWrapper.createInstance(context,
                                CipherWrapper.Type.LEGACY, KEYSTORE_KEYPAIR_ALIAS);
                        blob = cipher.encrypt(string);
                        type = cipher.getType();
                    }
                    catch (Exception e2)
                    {
                        FileLog.e(TAG, "Failed to encrypt a preference with fallback method.", e2);
                    }
                }
            }
        }

		Editor editor = getPrefs(context).edit();
		if (TextUtils.isEmpty(string))
		{
			editor.remove(PREF_BOXINFOLIST);
			editor.remove(PREF_BOXINFOLIST_CIPHER);
			editor.remove(PREF_BOXINFOLIST_CIPHERTAG);
		}
        else if (type != CipherWrapper.Type.NONE)
		{
			editor.putString(PREF_BOXINFOLIST_CIPHER, blob);
			editor.putString(PREF_BOXINFOLIST_CIPHERTAG, type.toString());
			editor.remove(PREF_BOXINFOLIST);
		}
		editor.apply();
	}

	/**
	 * Imports box setting
	 * (sets MRU time stamp of new entry to current time and saves list)
	 * 
	 * @param context
	 * 		the context for accessing preferences
	 * @param udn
	 *		the UDN of device which is not in this list
	 */
	public synchronized void importBox(Context context,
			String udn, String boxPassword,
			String voipName, String voipPassword, String voipTitle,
			String tam)
	{
		if (get(udn, false) == null)
		{
			try
			{
				// IP address doesn't have to be correct
				BoxInfo boxInfo = new BoxInfo(true, udn,
						Tr064Boxinfo.createDefaultUri("192.168.178.1").toURL(),
						"", null);
				boxInfo.setBoxPassword(boxPassword);
				boxInfo.setVoipCredentials(voipName, voipPassword);
				boxInfo.setVoIPTitle(voipTitle);
				boxInfo.setTam(tam);
				add(boxInfo);
				
				updateMru(context, boxInfo);
			}
			catch (Exception e)
			{
				FileLog.e(TAG, "Cannot import old box' preferences.", e);
			}
		}
	}
	
	/**
	 * Updates list on discovery 
	 * 
	 * @param tr064Boxinfo
	 * 		parsed TR-064 info to store along
	 * @return
	 * 		true if data changed which has to be saved to preferences
	 */
	public boolean foundBox(Tr064Boxinfo tr064Boxinfo)
	{
		try
		{
			String friendlyName = tr064Boxinfo.getFriendlyName();
			if (TextUtils.isEmpty(friendlyName))
				friendlyName = tr064Boxinfo.getModel();
			return foundBox(true, tr064Boxinfo.getUdn(),
					tr064Boxinfo.getUri().toURL(),
					tr064Boxinfo.getManufacturer() + " " + friendlyName, 
					tr064Boxinfo);
		}
		catch (MalformedURLException e)
		{
            FileLog.w(TAG, e.getMessage(), e);
		}
		return false;
	}
	
	/**
	 * Updates list on discovery 
	 * 
	 * @param isTR64
	 * 		true for TR-064 UDN
	 * @param udn
	 * 		the UDN
	 * @param location
	 *		the URL of device description
	 * @param server
	 * 		the server info string
	 * @return
	 * 		true if data changed which has to be saved to preferences
	 */
	public synchronized boolean foundBox(boolean isTR64, String udn,
			URL location, String server)
	{
		return foundBox(isTR64, udn, location, server, null);
	}

	private synchronized boolean foundBox(boolean isTR64, String udn,
			URL location, String server, Tr064Boxinfo tr064Boxinfo)
	{
		String host = location.getHost();
		// TODO exclusion of IPv6 addresses not tested
		if (host.contains(":")) return false; 
		// check manufacturer
		boolean isAvm = false;
		for(String token : server.split(" "))
			if (token.compareToIgnoreCase(MANUFACTURER) == 0)
			{
				isAvm = true;
				break;
			}
		if (!isAvm) return false;

		BoxInfo boxTr64 = null;
		BoxInfo boxNoTr64 = null;
		for (BoxInfo info : this)
		{
			if (info.getUdn().equals(udn))
			{
				// already in list
				if (isTR64)
					boxTr64 = info;
				else
					boxNoTr64 = info;
			}
			else if (info.isAvailable() && (info.isTr64() != isTR64) &&
					(info.getLocation().getHost().equals(host)))
			{
				// other interface already found on this host
				if (isTR64)
					boxNoTr64 = info;
				else
					boxTr64 = info;
			}
		}
		
		if (isTR64 && (boxNoTr64 != null))
		{
			remove(boxNoTr64); // now we have the better one
		}
		if (!isTR64 && (boxTr64 != null) && boxTr64.isAvailable())
		{
			return false; // we already have the better one
		}
		
		// add or update
		BoxInfo box = (isTR64) ? boxTr64 : boxNoTr64; 
		if (box != null)
		{
			box.updateLocation(location, server);
			if (tr064Boxinfo != null)
				box.updateTr064Boxinfo(tr064Boxinfo);
		}
		else add(new BoxInfo(isTR64, udn, location, server, tr064Boxinfo));

		return isTR64;
	}
}
