/*
 * Importer.java
 *
 * Copyright (C) 2009 to 2011 Tim Marston <tim@ed.am>
 *
 * This file is part of the Import Contacts program (hereafter referred
 * to as "this program"). For more information, see
 * http://ed.am/dev/android/import-contacts
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program 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
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package am.ed.importcontacts;

import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import android.content.SharedPreferences;
import android.os.Message;
import android.provider.Contacts.PhonesColumns;


public class Importer extends Thread
{
	public final static int ACTION_ABORT = 1;
	public final static int ACTION_ALLDONE = 2;

	public final static int RESPONSE_NEGATIVE = 0;
	public final static int RESPONSE_POSITIVE = 1;

	public final static int RESPONSEEXTRA_NONE = 0;
	public final static int RESPONSEEXTRA_ALWAYS = 1;

	private Doit _doit;
	private int _response;
	private int _response_extra;
	private int _merge_setting;
	private int _last_merge_decision;
	private boolean _abort = false;
	private boolean _is_finished = false;
	private ContactsCache _contacts_cache = null;
	private Backend _backend = null;

	/**
	 * Data about a contact
	 */
	public class ContactData
	{
		class TypeDetail
		{
			protected int _type;

			public TypeDetail( int type )
			{
				_type = type;
			}

			public int getType()
			{
				return _type;
			}
		}

		class PreferredDetail extends TypeDetail
		{
			protected boolean _is_preferred;

			public PreferredDetail( int type, boolean is_preferred )
			{
				super( type );
				_is_preferred = is_preferred;
			}

			public boolean isPreferred()
			{
				return _is_preferred;
			}
		}

		class ExtraDetail extends PreferredDetail
		{
			protected String _extra;

			public ExtraDetail( int type, boolean is_preferred, String extra )
			{
				super( type, is_preferred );

				if( extra != null ) extra = extra.trim();
				_extra = extra;
			}

			public String getExtra()
			{
				return _extra;
			}

			public void setExtra( String extra )
			{
				if( extra != null ) extra = extra.trim();
				_extra = extra;
			}
		}

		@SuppressWarnings("serial")
		protected class ContactNotIdentifiableException extends Exception
		{
		}

		protected String _name = null;
		protected String _primary_organisation = null;
		protected boolean _primary_organisation_is_preferred;
		protected String _primary_number = null;
		protected int _primary_number_type;
		protected boolean _primary_number_is_preferred;
		protected String _primary_email = null;
		protected boolean _primary_email_is_preferred;
		protected HashMap< String, ExtraDetail > _organisations = null;
		protected HashMap< String, PreferredDetail > _numbers = null;
		protected HashMap< String, PreferredDetail > _emails = null;
		protected HashMap< String, TypeDetail > _addresses = null;

		private ContactsCache.CacheIdentifier _cache_identifier = null;

		protected void setName( String name )
		{
			_name = name;
		}

		public boolean hasName()
		{
			return _name != null;
		}

		public String getName()
		{
			return _name;
		}

		protected void addOrganisation( String organisation, String title,
			boolean is_preferred )
		{
			organisation = organisation.trim();
			if( organisation.length() <= 0 )
			{
				// TODO: warn that an imported organisation is being ignored
				return;
			}

			if( title != null ) {
				title = title.trim();
				if( title.length() <= 0 ) title = null;
			}

			// add the organisation, as non-preferred (we prefer only one
			// organisation in finalise() after they're all imported)
			if( _organisations == null )
				_organisations = new HashMap< String, ExtraDetail >();
			if( !_organisations.containsKey( organisation ) )
				_organisations.put( organisation,
					new ExtraDetail( 0, false, title ) );

			// if this is the first organisation added, or it's a preferred
			// organisation and the current primary organisation isn't, then
			// record this as the primary organisation.
			if( _primary_organisation == null ||
				( is_preferred && !_primary_organisation_is_preferred ) )
			{
				_primary_organisation = organisation;
				_primary_organisation_is_preferred = is_preferred;
			}
		}

		public boolean hasOrganisations()
		{
			return _organisations != null && _organisations.size() > 0;
		}

		public HashMap< String, ExtraDetail > getOrganisations()
		{
			return _organisations;
		}

		public boolean hasPrimaryOrganisation()
		{
			return _primary_organisation != null;
		}

		public String getPrimaryOrganisation()
		{
			return _primary_organisation;
		}

		protected void addNumber( String number, int type,
			boolean is_preferred )
		{
			number = sanitisePhoneNumber( number );
			if( number == null )
			{
				// TODO: warn that an imported phone number is being ignored
				return;
			}

			// add the number, as non-preferred (we prefer only one number
			// in finalise() after they're all imported)
			if( _numbers == null )
				_numbers = new HashMap< String, PreferredDetail >();
			if( !_numbers.containsKey( number ) )
				_numbers.put( number,
					new PreferredDetail( type, false ) );

			final Set< Integer > non_voice_types = new HashSet< Integer >(
				Arrays.asList( PhonesColumns.TYPE_FAX_HOME,
					PhonesColumns.TYPE_FAX_WORK, PhonesColumns.TYPE_PAGER ) );

			// if this is the first number added, or it's a preferred number
			// and the current primary number isn't, or this number is on equal
			// standing with the primary number in terms of preference and it is
			// a voice number and the primary number isn't, then record this as
			// the primary number.
			if( _primary_number == null ||
				( is_preferred && !_primary_number_is_preferred ) ||
				( is_preferred == _primary_number_is_preferred &&
					!non_voice_types.contains( type ) &&
					non_voice_types.contains( _primary_number_type ) ) )
			{
				_primary_number = number;
				_primary_number_type = type;
				_primary_number_is_preferred = is_preferred;
			}
		}

		public boolean hasNumbers()
		{
			return _numbers != null && _numbers.size() > 0;
		}

		public HashMap< String, PreferredDetail > getNumbers()
		{
			return _numbers;
		}

		public boolean hasPrimaryNumber()
		{
			return _primary_number != null;
		}

		public String getPrimaryNumber()
		{
			return _primary_number;
		}

		protected void addEmail( String email, int type, boolean is_preferred )
		{

			email = sanitisesEmailAddress( email );
			if( email == null )
			{
				// TODO: warn that an imported email addtrss is being ignored
				return;
			}

			// add the email, as non-preferred (we prefer only one email in
			// finalise() after they're all imported)
			if( _emails == null )
				_emails = new HashMap< String, PreferredDetail >();
			if( !_emails.containsKey( email ) )
				_emails.put( email, new PreferredDetail( type, false ) );

			// if this is the first email added, or it's a preferred email and
			// the current primary organisation isn't, then record this as the
			// primary email.
			if( _primary_email == null ||
				( is_preferred && !_primary_email_is_preferred ) )
			{
				_primary_email = email;
				_primary_email_is_preferred = is_preferred;
			}
		}

		public boolean hasEmails()
		{
			return _emails != null && _emails.size() > 0;
		}

		public HashMap< String, PreferredDetail > getEmails()
		{
			return _emails;
		}

		public boolean hasPrimaryEmail()
		{
			return _primary_email != null;
		}

		public String getPrimaryEmail()
		{
			return _primary_email;
		}

		protected void addAddress( String address, int type )
		{
			address = address.trim();
			if( address.length() <= 0 )
			{
				// TODO: warn that an imported address is being ignored
				return;
			}

			if( _addresses == null ) _addresses =
				new HashMap< String, TypeDetail >();
			if( !_addresses.containsKey( address ) )
				_addresses.put( address, new TypeDetail( type ) );
		}

		public boolean hasAddresses()
		{
			return _addresses != null && _addresses.size() > 0;
		}

		public HashMap< String, TypeDetail > getAddresses()
		{
			return _addresses;
		}

		protected void finalise()
			throws ContactNotIdentifiableException
		{
			// ensure that if there is a primary number, it is preferred so
			// that there is always one preferred number. Android will assign
			// preference to one anyway so we might as well decide one sensibly.
			if( _primary_number != null ) {
				PreferredDetail data = _numbers.get( _primary_number );
				_numbers.put( _primary_number,
					new PreferredDetail( data.getType(), true ) );
			}

			// do the same for the primary email
			if( _primary_email != null ) {
				PreferredDetail data = _emails.get( _primary_email );
				_emails.put( _primary_email,
					new PreferredDetail( data.getType(), true ) );
			}

			// do the same for the primary organisation
			if( _primary_organisation != null ) {
				ExtraDetail data = _organisations.get( _primary_organisation );
				_organisations.put( _primary_organisation,
					new ExtraDetail( 0, true, data.getExtra() ) );
			}

			// create a cache identifier from this contact data, which can be
			// used to look-up an existing contact
			_cache_identifier = ContactsCache.createIdentifier( this );
			if( _cache_identifier == null )
				throw new ContactNotIdentifiableException();
		}

		public ContactsCache.CacheIdentifier getCacheIdentifier()
		{
			return _cache_identifier;
		}

		private String sanitisePhoneNumber( String number )
		{
			number = number.trim();
			Pattern p = Pattern.compile( "^[-\\(\\) \\+0-9#*]+" );
			Matcher m = p.matcher( number );
			if( m.lookingAt() ) return m.group( 0 );
			return null;
		}

		private String sanitisesEmailAddress( String email )
		{
			email = email.trim();
			Pattern p = Pattern.compile(
				"^[^ @]+@[a-zA-Z]([-a-zA-Z0-9]*[a-zA-z0-9])?(\\.[a-zA-Z]([-a-zA-Z0-9]*[a-zA-z0-9])?)+$" );
			Matcher m = p.matcher( email );
			if( m.matches() ) {
				String[] bits = email.split( "@" );
				return bits[ 0 ] + "@" + bits[ 1 ].toLowerCase();
			}
			return null;
		}
	}

	@SuppressWarnings("serial")
	protected class AbortImportException extends Exception { };

	public Importer( Doit doit )
	{
		_doit = doit;

		SharedPreferences prefs = getSharedPreferences();
		_merge_setting = prefs.getInt( "merge_setting", Doit.ACTION_PROMPT );
	}

	@Override
	public void run()
	{
		try
		{
			// update UI
			setProgressMessage( R.string.doit_caching );

//			if( Integer.parseInt( android.os.Build.VERSION.SDK ) >= 5 )
//				_backend = new ContactsContractBackend();
//			else
				_backend = new ContactsBackend( _doit );

			// create a cache of existing contacts and populate it
			_contacts_cache = new ContactsCache();
			_backend.populateCache( _contacts_cache );

			// do the import
			onImport();

			// done!
			finish( ACTION_ALLDONE );
		}
		catch( AbortImportException e )
		{}

		// flag as finished to prevent interrupts
		setIsFinished();
	}

	synchronized private void setIsFinished()
	{
		_is_finished = true;
	}

	protected void onImport() throws AbortImportException
	{
	}

	public void wake()
	{
		wake( 0, RESPONSEEXTRA_NONE );
	}

	public void wake( int response )
	{
		wake( response, RESPONSEEXTRA_NONE );
	}

	synchronized public void wake( int response, int response_extra )
	{
		_response = response;
		_response_extra = response_extra;
		notify();
	}

	synchronized public boolean setAbort()
	{
		if( !_is_finished && !_abort ) {
			_abort = true;
			notify();
			return true;
		}
		return false;
	}

	protected SharedPreferences getSharedPreferences()
	{
		return _doit.getSharedPreferences();
	}

	protected void showError( int res ) throws AbortImportException
	{
		showError( _doit.getText( res ).toString() );
	}

	synchronized protected void showError( String message )
			throws AbortImportException
	{
		checkAbort();
		_doit._handler.sendMessage( Message.obtain(
			_doit._handler, Doit.MESSAGE_ERROR, message ) );
		try {
			wait();
		}
		catch( InterruptedException e ) { }

		// no need to check if an abortion happened during the wait, we are
		// about to finish anyway!
		finish( ACTION_ABORT );
	}

	protected void showFatalError( int res ) throws AbortImportException
	{
		showFatalError( _doit.getText( res ).toString() );
	}

	synchronized protected void showFatalError( String message )
			throws AbortImportException
	{
		checkAbort();
		_doit._handler.sendMessage( Message.obtain(
			_doit._handler, Doit.MESSAGE_ERROR, message ) );
		try {
			wait();
		}
		catch( InterruptedException e ) { }

		// no need to check if an abortion happened during the wait, we are
		// about to finish anyway!
		finish( ACTION_ABORT );
	}

	protected boolean showContinue( int res ) throws AbortImportException
	{
		return showContinue( _doit.getText( res ).toString() );
	}

	synchronized protected boolean showContinue( String message )
			throws AbortImportException
	{
		checkAbort();
		_doit._handler.sendMessage( Message.obtain(
			_doit._handler, Doit.MESSAGE_CONTINUEORABORT, message ) );
		try {
			wait();
		}
		catch( InterruptedException e ) { }

		// check if an abortion happened during the wait
		checkAbort();

		return _response == RESPONSE_POSITIVE;
	}

	protected void setProgressMessage( int res ) throws AbortImportException
	{
		checkAbort();
		_doit._handler.sendMessage( Message.obtain( _doit._handler,
			Doit.MESSAGE_SETPROGRESSMESSAGE, getText( res ) ) );
	}

	protected void setProgressMax( int max_progress )
			throws AbortImportException
	{
		checkAbort();
		_doit._handler.sendMessage( Message.obtain(
			_doit._handler, Doit.MESSAGE_SETMAXPROGRESS,
			new Integer( max_progress ) ) );
	}

	protected void setTmpProgress( int tmp_progress )
		throws AbortImportException
	{
		checkAbort();
		_doit._handler.sendMessage( Message.obtain(
			_doit._handler, Doit.MESSAGE_SETTMPPROGRESS,
			new Integer( tmp_progress ) ) );
	}

	protected void setProgress( int progress ) throws AbortImportException
	{
		checkAbort();
		_doit._handler.sendMessage( Message.obtain(
			_doit._handler, Doit.MESSAGE_SETPROGRESS,
			new Integer( progress ) ) );
	}

	protected void finish( int action ) throws AbortImportException
	{
		// update UI to reflect action
		int message;
		switch( action )
		{
		case ACTION_ALLDONE:	message = Doit.MESSAGE_ALLDONE; break;
		default:	// fall through
		case ACTION_ABORT:		message = Doit.MESSAGE_ABORT; break;
		}
		_doit._handler.sendEmptyMessage( message );

		// stop
		throw new AbortImportException();
	}

	protected CharSequence getText( int res )
	{
		return _doit.getText( res );
	}

	synchronized private boolean checkForDuplicate(
		ContactsCache.CacheIdentifier cache_identifier, int merge_setting )
		throws AbortImportException
	{
		_last_merge_decision = merge_setting;

		// it is ok to use contact.getCacheIdentifier(). The contact has already
		// been finalised, which means a valid cache identifier will have been
		// created for it (or it would have been skipped)

		// handle special cases
		switch( merge_setting )
		{
		case Doit.ACTION_KEEP:
			// if we keep contacts on duplicate, we better check for one
			return !_contacts_cache.canLookup( cache_identifier );

		case Doit.ACTION_PROMPT:
			// if we are prompting on duplicate, we better check for one and if
			// the contact doesn'te exist, we want to import it
			if( !_contacts_cache.canLookup( cache_identifier ) )
				return true;

			// ok, it exists, so do prompt
			_doit._handler.sendMessage( Message.obtain( _doit._handler,
				Doit.MESSAGE_MERGEPROMPT, cache_identifier.getDetail() ) );
			try {
				wait();
			}
			catch( InterruptedException e ) { }

			// check if an abortion happened during the wait
			checkAbort();

			// if "always" was selected, make choice permanent
			if( _response_extra == RESPONSEEXTRA_ALWAYS )
				_merge_setting = _response;

			// recurse, with our new merge setting
			return checkForDuplicate( cache_identifier, _response );
		}

		// for all other cases (either overwriting or merging) we will need the
		// imported data
		return true;
	}

	protected void skipContact() throws AbortImportException
	{
		checkAbort();
		_doit._handler.sendEmptyMessage( Doit.MESSAGE_CONTACTSKIPPED );
	}

	protected void importContact( ContactData contact )
			throws AbortImportException
	{
		checkAbort();

		// It is expected that we use contact.getCacheIdentifier() here. The
		// contact we are passed should have been successfully finalise()d,
		// which includes generating a valid cache identifier.
		ContactsCache.CacheIdentifier cache_identifier =
			contact.getCacheIdentifier();

		// check to see if this contact is a duplicate and should be skipped
		if( !checkForDuplicate( cache_identifier, _merge_setting ) ) {
			skipContact();
			return;
		}

//		if( !showContinue( "====[ IMPORTING ]====\n: " + contact._name ) )
//			finish( ACTION_ABORT );

		// keep track of whether we've informed the UI of what we're doing
		boolean ui_informed = false;

		// attempt to lookup the id of an existing contact in the cache with
		// this contact data's cache identifier
		Long id = (Long)_contacts_cache.lookup( cache_identifier );

		// does contact exist already?
		if( id != null )
		{
			// should we skip this import altogether?
			if( _last_merge_decision == Doit.ACTION_KEEP ) return;

			// should we destroy the existing contact before importing?
			if( _last_merge_decision == Doit.ACTION_OVERWRITE )
			{
				// remove from device
				_backend.deleteContact( id );

				// update cache
				_contacts_cache.removeLookup( contact.getCacheIdentifier() );
				_contacts_cache.removeAssociatedData( id );

				// show that we're overwriting a contact
				_doit._handler.sendEmptyMessage(
						Doit.MESSAGE_CONTACTOVERWRITTEN );
				ui_informed = true;

				// discard the contact id
				id = null;
			}
		}

		// if we don't have a contact id yet (or we did, but we destroyed it
		// when we deleted the contact), we'll have to create a new contact
		if( id == null )
		{
			// create a new contact
			id = _backend.addContact( contact._name );
			if( id == null || id <= 0 )
				showError( R.string.error_unabletoaddcontact );

			// update cache
			_contacts_cache.addLookup(
				ContactsCache.createIdentifier( contact ), id );

			// if we haven't already shown that we're overwriting a contact,
			// show that we're creating a new contact
			if( !ui_informed ) {
				_doit._handler.sendEmptyMessage( Doit.MESSAGE_CONTACTCREATED );
				ui_informed = true;
			}
		}

		// if we haven't already shown that we're overwriting or creating a
		// contact, show that we're merging a contact
		if( !ui_informed )
			_doit._handler.sendEmptyMessage( Doit.MESSAGE_CONTACTMERGED );

		// import contact parts
		if( contact.hasNumbers() )
			importContactPhones( id, contact.getNumbers() );
		if( contact.hasEmails() )
			importContactEmails( id, contact.getEmails() );
		if( contact.hasAddresses() )
			importContactAddresses( id, contact.getAddresses() );
		if( contact.hasOrganisations() )
			importContactOrganisations( id, contact.getOrganisations() );
	}

	private void importContactPhones( Long id,
			HashMap< String, ContactData.PreferredDetail > datas )
	{
		// add phone numbers
		Set< String > datas_keys = datas.keySet();
		Iterator< String > i = datas_keys.iterator();
		while( i.hasNext() ) {
			String number = i.next();
			ContactData.PreferredDetail data = datas.get( number );

			// we don't want to add this number if it's crap, or it already
			// exists (which would cause a duplicate to be created). We don't
			// take in to account the type when checking for duplicates. This is
			// intentional: types aren't really very reliable. We assume that
			// if the number exists at all, it doesn't need importing. Because
			// of this, we also can't update the cache (which we don't need to
			// anyway, so it's not a problem).
			if( _contacts_cache.hasAssociatedNumber( id, number ) )
				continue;

			// add phone number
			_backend.addContactPhone( id, number, data );

			// and add this address to the cache to prevent a addition of
			// duplicate date from another file
			_contacts_cache.addAssociatedNumber( id, number );
		}
	}

	private void importContactEmails( Long id,
			HashMap< String, ContactData.PreferredDetail > datas )
	{
		// add email addresses
		Set< String > datas_keys = datas.keySet();
		Iterator< String > i = datas_keys.iterator();
		while( i.hasNext() ) {
			String email = i.next();
			ContactData.PreferredDetail data = datas.get( email );

			// we don't want to add this email address if it exists already or
			// we would introduce duplicates.
			if( _contacts_cache.hasAssociatedEmail( id, email ) )
				continue;

			// add phone number
			_backend.addContactEmail( id, email, data );

			// and add this address to the cache to prevent a addition of
			// duplicate date from another file
			_contacts_cache.addAssociatedEmail( id, email );
		}
	}

	private void importContactAddresses( Long id,
		HashMap< String, ContactData.TypeDetail > datas )
	{
		// add addresses
		Set< String > datas_keys = datas.keySet();
		Iterator< String > i = datas_keys.iterator();
		while( i.hasNext() ) {
			String address = i.next();
			ContactData.TypeDetail data = datas.get( address );

			// we don't want to add this address if it exists already or we
			// would introduce duplicates
			if( _contacts_cache.hasAssociatedAddress( id, address ) )
				continue;

			// add postal address
			_backend.addContactAddresses( id, address, data );

			// and add this address to the cache to prevent a addition of
			// duplicate date from another file
			_contacts_cache.addAssociatedAddress( id, address );
		}
	}

	private void importContactOrganisations( Long id,
		HashMap< String, ContactData.ExtraDetail > datas )
	{
		// add addresses
		Set< String > datas_keys = datas.keySet();
		Iterator< String > i = datas_keys.iterator();
		while( i.hasNext() ) {
			String organisation = i.next();
			ContactData.ExtraDetail data = datas.get( organisation );

			// we don't want to add this address if it exists already or we
			// would introduce duplicates
			if( _contacts_cache.hasAssociatedOrganisation( id, organisation ) )
				continue;

			// add organisation address
			_backend.addContactOrganisation( id, organisation, data );

			// and add this address to the cache to prevent a addition of
			// duplicate date from another file
			_contacts_cache.addAssociatedOrganisation( id, organisation );
		}
	}

	synchronized protected void checkAbort() throws AbortImportException
	{
		if( _abort ) {
			// stop
			throw new AbortImportException();
		}
	}
}
