package info.staticfree.android.units;
/*
 * UnitUsageDBHelper.java
 * Copyright (C) 2010  Steve Pomeroy <steve@staticfree.info>
 *
 * 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/>.
 */
import java.io.BufferedReader;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import net.sourceforge.unitsinjava.BuiltInFunction;
import net.sourceforge.unitsinjava.DefinedFunction;
import net.sourceforge.unitsinjava.EvalError;
import net.sourceforge.unitsinjava.Unit;
import net.sourceforge.unitsinjava.Value;

import org.json.JSONException;
import org.json.JSONObject;

import android.Manifest;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.Environment;
import android.os.Handler;
import android.os.Message;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.View;
import android.view.View.OnFocusChangeListener;
import android.widget.SimpleCursorAdapter;
import android.widget.TextView;

/**
 * In order to sort through all the possible units, a database of weights is used.
 * Units with a higher weight are shown first in all lists.
 * Weights are initialized from static JSON files, including regional weights (eg.
 * to put imperial units above metric in the US).
 *
 * @author steve
 *
 */
public class UnitUsageDBHelper extends SQLiteOpenHelper {
	public final static String TAG = UnitUsageDBHelper.class.getSimpleName();
	public static final String
		DB_NAME = "units",
		DB_USAGE_TABLE = "usage",
		DB_CLASSIFICATION_TABLE = "classification",

		DB_USAGE_INDEX = "factor_fprints",
		DB_CLASSIFICATION_INDEX = "factor_fprints_classification";

	private final Context context;

	// TODO add a preference that remembers the last loaded version. Load new
	// units and fingerprints.
	@SuppressWarnings("unused")
	private static final String UNITS_DAT_VERSION = "1.50";
	private static final int DB_VERSION = 5;

	public UnitUsageDBHelper(Context context) {
		super(context, DB_NAME, null, DB_VERSION);
		this.context = context;
	}

	@Override
	public void onCreate(SQLiteDatabase db) {
		db.execSQL("CREATE TABLE '"+DB_USAGE_TABLE+
				"' ("+
				"'" + UsageEntry._ID 			+ "' INTEGER PRIMARY KEY," +
				"'" + UsageEntry._UNIT			+ "' TEXT UNIQUE ON CONFLICT IGNORE," +
				"'" + UsageEntry._USE_COUNT 	+ "' INTEGER," +
				"'" + UsageEntry._FACTOR_FPRINT + "' TEXT" +
				")");
		db.execSQL("CREATE INDEX '"+DB_USAGE_INDEX+"' ON "+DB_USAGE_TABLE + " (" +UsageEntry._FACTOR_FPRINT+ ")");

		db.execSQL("CREATE TABLE '"+DB_CLASSIFICATION_TABLE+
				"' ("+
				"'" + ClassificationEntry._ID 				+ "' INTEGER PRIMARY KEY," +
				"'" + ClassificationEntry._DESCRIPTION 		+ "' TEXT," +
				"'"	+ ClassificationEntry._FACTOR_FPRINT 	+ "' TEXT UNIQUE"+
				")");
		db.execSQL("CREATE UNIQUE INDEX '"+DB_CLASSIFICATION_INDEX+"' ON "+DB_CLASSIFICATION_TABLE + " (" +ClassificationEntry._FACTOR_FPRINT+ ")");
	}

	@Override
	public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
		db.execSQL("DROP TABLE IF EXISTS "+ DB_USAGE_TABLE);
		db.execSQL("DROP TABLE IF EXISTS "+ DB_CLASSIFICATION_TABLE);

		db.execSQL("DROP INDEX IF EXISTS "+ DB_USAGE_INDEX);
		db.execSQL("DROP INDEX IF EXISTS "+ DB_CLASSIFICATION_INDEX);
		onCreate(db);

	}

	public int getUnitUsageDbCount(){
		final SQLiteDatabase db = getReadableDatabase();
		final String[] proj = {UsageEntry._ID};
		if (!db.isOpen()){
			return -1;
		}
		final Cursor c = db.query(DB_USAGE_TABLE, proj, null, null, null, null, null);
		c.moveToFirst();
		final int count = c.getCount();
		c.close();
		db.close();
		return count;
	}

	@SuppressWarnings("unchecked")
	public HashMap<String,String> loadFingerprints(){
		final HashMap<String, String> fingerprints = new HashMap<String, String>();
		try {
			final JSONObject fprints = loadJsonObjectFromRawResource(context, R.raw.fingerprints);
			for (final Iterator i = fprints.keys(); i.hasNext(); ){
				final String key = (String) i.next();
				fingerprints.put(key, fprints.optString(key));
			}
		} catch (final Exception e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		return fingerprints;
	}


	public static String getFingerprint(String unitName){

		String fpr = null;
		try {
			Value unit;
			// this is done here to allow for unicode that maps to functions
			unitName = Units.unicodeToAscii(unitName);
			if (unitName.endsWith("(")){
				final DefinedFunction f = DefinedFunction.table.get(unitName.substring(0, unitName.length()-1));
				if (f != null){
					unit = f.getConformability();
				}else{
					// non-DefinedFunctions are built in and compatible with numbers.
					unit = Value.fromString("0");
				}
			}else{
				unit = ValueGui.fromUnicodeString(unitName);

			}
			if (unit != null){
				fpr = ValueGui.getFingerprint(unit);
			}
		}catch (final EvalError e){
			// skip things we can't handle
		}
		return fpr;
	}

	public void loadInitialUnitUsage(){
		final SQLiteDatabase db = getWritableDatabase();
		// load the initial table in
		final ContentValues cv = new ContentValues();

		Log.d(TAG, "init all weights hash");
		final HashMap<String, Integer> allUnitWeights =
			new HashMap<String, Integer>(Unit.table.keySet().size());
		Log.d(TAG, "adding all known weights...");
		for (final String unitName: Unit.table.keySet()){
			// don't add all uppercase names
			if (! unitName.toUpperCase().equals(unitName)){
				allUnitWeights.put(unitName, 0);
			}
		}
		Log.d(TAG, "adding all known functions...");
		for (final String functionName: BuiltInFunction.table.keySet()){
			allUnitWeights.put(functionName + "(", 0);
		}
//		for (final String functionName: TabularFunction.table.keySet()){
//			allUnitWeights.put(functionName + "(", 0);
//		}
//		for (final String functionName: ComputedFunction.table.keySet()){
//			allUnitWeights.put(functionName + "(", 0);
//		}
		for (final String functionName: DefinedFunction.table.keySet()){
			allUnitWeights.put(functionName + "(", 0);
		}
		Log.d(TAG, "adding common weights");
		addAll(loadInitialWeights(R.raw.common_weights), allUnitWeights);
		Log.d(TAG, "adding regional weights");
		addAll(loadInitialWeights(R.raw.regional_weights), allUnitWeights);

		// This is so that things of common weight end up in non-random order
		// without having to do an SQL order-by.
		final ArrayList<String> sortedUnits = new ArrayList<String>(allUnitWeights.keySet());
		Log.d(TAG, "Sorting units...");
		Collections.sort(sortedUnits);
		Log.d(TAG, "Adding all sorted units...");

		final HashMap<String,String> fingerprints = loadFingerprints();

		db.beginTransaction();
		for (final String unitName: sortedUnits){
			cv.put(UsageEntry._UNIT, unitName);
			cv.put(UsageEntry._USE_COUNT, allUnitWeights.get(unitName));

			final String fpr = fingerprints.containsKey(unitName) ? fingerprints.get(unitName) : getFingerprint(unitName);

			fingerprints.put(unitName, fpr);
			cv.put(UsageEntry._FACTOR_FPRINT, fpr);
			db.insert(DB_USAGE_TABLE, null, cv);
		}
		db.setTransactionSuccessful();
		db.endTransaction();
		db.close();

		context.getContentResolver().notifyChange(UsageEntry.CONTENT_URI, null);

		// If we have the right permissons, save the fingerprints file to a JSON file
		// which can then be imported into the app to speed up initial fingerpint loading.
		if (context.checkCallingOrSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED){
			final File externalStorage = Environment.getExternalStorageDirectory();
			final File fprintsOutput = new File(externalStorage, "units_fingerprints.json");
			final JSONObject jo = new JSONObject(fingerprints);
			try {
				final FileWriter fw = new FileWriter(fprintsOutput);
				fw.write(jo.toString(1));
				fw.close();
				Log.i(TAG, "fingerprints written to: "+fprintsOutput.getCanonicalPath());

			} catch (final Exception e) {
				e.printStackTrace();
			}
		}
		Log.d(TAG, "done!");
	}

	@SuppressWarnings("unchecked")
	public void loadUnitClassifications(){
		final SQLiteDatabase db = getWritableDatabase();
		final JSONObject jo = loadInitialWeights(R.raw.unit_classification);

		db.beginTransaction();
		final ContentValues cv = new ContentValues();
		for (final Iterator i = jo.keys(); i.hasNext(); ){
			final String unit = (String)i.next();
			final String description = jo.optString(unit);
			final String fprint = getFingerprint(unit);
			cv.put(ClassificationEntry._FACTOR_FPRINT, fprint);
			cv.put(ClassificationEntry._DESCRIPTION, description);
			db.insert(DB_CLASSIFICATION_TABLE, null, cv);
		}
		db.setTransactionSuccessful();
		db.endTransaction();
		db.close();
		Log.d(TAG, "Successfully added "+jo.length()+" classification entries.");
	}

	@SuppressWarnings("unchecked")
	private void addAll(JSONObject unitWeights, HashMap<String, Integer> allWeights){
		for (final Iterator i = unitWeights.keys(); i.hasNext(); ){
			final String key = (String)i.next();
			if (allWeights.containsKey(key)){
				allWeights.put(key, allWeights.get(key) + unitWeights.optInt(key));
			}else{
				allWeights.put(key, unitWeights.optInt(key));
			}
		}
	}

	/**
	 * A weight file is just a static JSON file used to set the initial
	 * weights for unit recommendation precedence.
	 *
	 * @param resourceId
	 * @return
	 */
	@SuppressWarnings("unchecked")
	private JSONObject loadInitialWeights(int resourceId){
		try{

			final JSONObject jo = loadJsonObjectFromRawResource(context, resourceId);

			// remove all "comments", which are just key entries that start with "--"
			for (final Iterator i = jo.keys(); i.hasNext(); ){
				final String key = (String)i.next();
				if (key.startsWith("--")){
					i.remove();
				}
			}

			return jo;

		}catch (final Exception e){
			e.printStackTrace();
			return null;
		}
	}

	private static JSONObject loadJsonObjectFromRawResource(Context context, int resourceId) throws IOException, JSONException{
		final InputStream is = context.getResources().openRawResource(resourceId);

		final StringBuilder jsonString = new StringBuilder();

		for (final BufferedReader isReader = new BufferedReader(new InputStreamReader(is), 16000);
		isReader.ready();){
			jsonString.append(isReader.readLine());
		}
		return new JSONObject(jsonString.toString());
	}


	public final static String USAGE_SORT =  UsageEntry._USE_COUNT + " DESC, "+UsageEntry._UNIT + " ASC";
	private static final String CONFORMING_SELECTION = UsageEntry._FACTOR_FPRINT + " = ?";

	private static String cachedEntryText;
	private static String[] cachedEntryFprintArgs;

	/**
	 * @param otherEntry
	 * @return
	 */
	private synchronized static String[] getConformingSelectionArgs(TextView otherEntry){
		final String otherEntryText = otherEntry.getText().toString();
		if (otherEntryText.length() > 0){
			if (otherEntryText.toString().equals(cachedEntryText)){
				return cachedEntryFprintArgs;
			}
			try {
				final String[] conformingSelectionArgs = {ValueGui.getFingerprint(ValueGui.fromUnicodeString(ValueGui.closeParens(otherEntryText)))};
				cachedEntryText = otherEntryText;
				cachedEntryFprintArgs = conformingSelectionArgs;
				return conformingSelectionArgs;
			}catch (final EvalError e){
				return null;
			}
		}
		return null;
	}

	public static class UnitCursorAdapter extends SimpleCursorAdapter {

		private static final int MSG_REQUERY = 0;
		private boolean runningQuery;
		private final ContentResolver mContentResolver;
		private final Activity mActivity;
		private final TextView mOtherEntry;
		private final Handler mHandler = new Handler(){
			private int retryCount = 0;
			@Override
			public void handleMessage(Message msg) {
				switch (msg.what){
				case MSG_REQUERY:
					if (!runningQuery){
						getFilter().filter(null);
						//notifyDataSetInvalidated();
						retryCount = 0;
					}else{
						// XXX hack to work around race condition when clearing
						// both fields at the same time.
						if (retryCount < 2){
							mHandler.sendEmptyMessageDelayed(MSG_REQUERY, 100);
							retryCount++;
						}
					}
					break;
				}
			};
		};
		public UnitCursorAdapter (Activity context, Cursor dbCursor, TextView otherEntry){
			super(context, android.R.layout.simple_dropdown_item_1line,
					dbCursor,
					new String[] {UsageEntry._UNIT},
					new int[] {android.R.id.text1});

			mActivity = context;
			mContentResolver = context.getContentResolver();
			setStringConversionColumn(dbCursor.getColumnIndex(UsageEntry._UNIT));

			final TextUpdateWatcher tuw = new TextUpdateWatcher(this);
			otherEntry.addTextChangedListener(tuw);
			otherEntry.setOnFocusChangeListener(tuw);

			mOtherEntry = otherEntry;
		}

		public synchronized void otherEntryUpdated(){
			if (!mHandler.hasMessages(MSG_REQUERY)){
				mHandler.sendEmptyMessage(MSG_REQUERY);
			}
		}

		@Override
		public void changeCursor(Cursor c) {
			mActivity.stopManagingCursor(getCursor());

			super.changeCursor(c);
			mActivity.startManagingCursor(c);
		}

		@Override
		public Cursor runQueryOnBackgroundThread(CharSequence constraint) {
			if (getFilterQueryProvider() != null){
				return getFilterQueryProvider().runQuery(constraint);
			}

			runningQuery = true;

			final String[] selectionArgs = null;
			final String selection = null;
			Cursor c = null;

			if (constraint == null || constraint.length() == 0){
				c = queryWithConforming(mOtherEntry, selection, selectionArgs);
			}else{
				final Matcher m = UNIT_EXTRACT_REGEX.matcher(constraint);
				String modConstraint;
				if (m.matches()){
					modConstraint = m.group(1);
					c = queryWithConforming(mOtherEntry, UsageEntry._UNIT +" GLOB ?", new String[] {modConstraint+"*"});
				}else{
					c = queryWithConforming(mOtherEntry, null, null);
				}
			}
			runningQuery = false;
			return c;
		}

		private Cursor queryWithConforming(TextView otherEntry, String selection, String[] selectionArgs){
			String conformingSelection = null;
			String[] conformingSelectionArgs = getConformingSelectionArgs(otherEntry);

			if (selection != null){
				if (conformingSelectionArgs != null){
					conformingSelection = CONFORMING_SELECTION + " AND " + selection;
					final ArrayList<String> args = new ArrayList<String>();
					args.add(conformingSelectionArgs[0]);
					args.addAll(Arrays.asList(selectionArgs));
					conformingSelectionArgs = args.toArray(new String[]{});
				}else{
					conformingSelection = selection;
					conformingSelectionArgs = selectionArgs;
				}
			}else if (conformingSelectionArgs != null){
				conformingSelection = CONFORMING_SELECTION;
			}
			Cursor c = mContentResolver.query(UsageEntry.CONTENT_URI, null, conformingSelection, conformingSelectionArgs, USAGE_SORT);
			// If we don't get anything by conforming, the user may be attempting to ask for
			// a complex result and we should just return everything.
			if (c.getCount() == 0){
				c.close();
				c = mContentResolver.query(UsageEntry.CONTENT_URI, null, selection, selectionArgs, USAGE_SORT);
			}
			return c;
		}

		private static class TextUpdateWatcher implements TextWatcher, OnFocusChangeListener {
			private boolean dirty = false;

			private final UnitCursorAdapter adapter;

			public TextUpdateWatcher(UnitCursorAdapter adapter) {
				this.adapter = adapter;
			}
			public void afterTextChanged(Editable s) {}

			public void beforeTextChanged(CharSequence s, int start, int count,
					int after) {}

			public void onTextChanged(CharSequence s, int start, int before,
					int count) {
				if (s.length() == 0){
					// change immediately if cleared.
					adapter.otherEntryUpdated();
					dirty = false;
				}else{
					dirty = true;
				}
			}

			public void onFocusChange(View v, boolean hasFocus) {
				if (!hasFocus && dirty){
					dirty = false;
					adapter.otherEntryUpdated();
				}
			}
		}
	}

	private static final Pattern UNIT_REGEX = Pattern.compile("([a-zA-Z]\\w+)");
	private static final Pattern UNIT_EXTRACT_REGEX = Pattern.compile(".*?([a-zA-Z]\\w+)");
	private static final Pattern UNIT_EXPONENTIAL_REGEX = Pattern.compile("[eE]");


	private final static String[] INCREMENT_QUERY_PROJECTION = {UsageEntry._ID, UsageEntry._USE_COUNT, UsageEntry._UNIT};
	/**
	 * Increments the usage counter for the given unit.
	 * @param unit name of the unit
	 * @param db the unit usage database
	 */
	public static void logUnitUsed(String unit, ContentResolver cr){
		final String[] selectionArgs = {unit};
		final Cursor c = cr.query(UsageEntry.CONTENT_URI, INCREMENT_QUERY_PROJECTION, UsageEntry._UNIT + "=?", selectionArgs, null);
		if (c.getCount() > 0){
			c.moveToFirst();
			final int useCount = c.getInt(c.getColumnIndex(UsageEntry._USE_COUNT));
			final int id = c.getInt(c.getColumnIndex(UsageEntry._ID));
			final ContentValues cv = new ContentValues();
			cv.put(UsageEntry._USE_COUNT, useCount+1);

			cr.update(ContentUris.withAppendedId(UsageEntry.CONTENT_URI, id), cv, null, null);
		}else{
			final ContentValues cv = new ContentValues();
			cv.put(UsageEntry._UNIT, unit);
			cv.put(UsageEntry._USE_COUNT, 1);
			cv.put(UsageEntry._FACTOR_FPRINT, getFingerprint(unit));
			cr.insert(UsageEntry.CONTENT_URI, cv);
		}
		c.close();
	}

	public static void logUnitsInExpression(String expression, ContentResolver cr){
		final Matcher m = UNIT_REGEX.matcher(expression);
		while(m.find()){
			String unit = m.group(1);
			// functions are noted by ending in an open paren.
			if (DefinedFunction.table.containsKey(unit) || BuiltInFunction.table.containsKey(unit)){
				unit += "(";
			}
			logUnitUsed(unit, cr);
		}
	}

}
