package medic.gateway.alert;

import android.content.ContentValues;
import android.content.Context;
import android.database.sqlite.SQLiteConstraintException;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteStatement;
import android.database.Cursor;
import android.database.SQLException;
import android.telephony.SmsMessage;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static java.util.UUID.randomUUID;
import static medic.gateway.alert.BuildConfig.DEBUG;
import static medic.gateway.alert.BuildConfig.FORCE_SEED;
import static medic.gateway.alert.BuildConfig.LOAD_SEED_DATA;
import static medic.gateway.alert.GatewayLog.logEvent;
import static medic.gateway.alert.GatewayLog.logException;
import static medic.gateway.alert.GatewayLog.trace;
import static medic.gateway.alert.GatewayLog.warnException;
import static medic.gateway.alert.Utils.args;
import static medic.gateway.alert.DebugUtils.randomPhoneNumber;
import static medic.gateway.alert.DebugUtils.randomSmsContent;

@SuppressWarnings({"PMD.GodClass", "PMD.TooManyMethods"})
public final class Db extends SQLiteOpenHelper {
	private static final int SCHEMA_VERSION = 6;

	private static final String ALL = null, NO_GROUP = null;
	private static final String[] NO_ARGS = {};
	private static final String NO_CRITERIA = null;
	private static final String NO_LIMIT = null;
	private static final String DEFAULT_SORT_ORDER = null;

	private static final String tblLOG = "log";
	private static final String LOG_clmID = "_id";
	private static final String LOG_clmTIMESTAMP = "timestamp";
	private static final String LOG_clmMESSAGE = "message";

	private static final String tblWT_MESSAGE = "wt_message";
	private static final String WTM_clmID = "_id";
	private static final String WTM_clmSTATUS = "status";
	private static final String WTM_clmLAST_ACTION = "last_action";
	private static final String WTM_clmFROM = "_from";
	private static final String WTM_clmCONTENT = "content";
	private static final String WTM_clmSMS_SENT = "sms_sent";
	private static final String WTM_clmSMS_RECEIVED = "sms_received";

	private static final String tblWT_MESSAGE_PART = "wt_message_part";
	private static final String WMP_clmFROM = "_from";
	private static final String WMP_clmCONTENT = "content";
	private static final String WMP_clmSENT = "sent";
	private static final String WMP_clmRECEIVED = "received";
	private static final String WMP_clmMP_REF = "mp_reference";
	private static final String WMP_clmMP_PART = "mp_part_number";
	private static final String WMP_clmMP_TOTAL_PARTS = "mp_total_parts";

	private static final String tblWT_STATUS = "wtm_status";
	private static final String WTS_clmID = "_id";
	private static final String WTS_clmMESSAGE_ID = "message_id";
	private static final String WTS_clmSTATUS = "status";
	private static final String WTS_clmTIMESTAMP = "timestamp";

	private static final String tblWO_MESSAGE = "wo_message";
	private static final String WOM_clmID = "_id";
	private static final String WOM_clmSTATUS = "status";
	private static final String WOM_clmSTATUS_NEEDS_FORWARDING = "status_needs_forwarding";
	private static final String WOM_clmFAILURE_REASON = "failure_reason";
	private static final String WOM_clmLAST_ACTION = "last_action";
	private static final String WOM_clmTO = "_to";
	private static final String WOM_clmCONTENT = "content";

	private static final String tblWO_STATUS = "wom_status";
	private static final String WOS_clmID = "_id";
	private static final String WOS_clmMESSAGE_ID = "message_id";
	private static final String WOS_clmSTATUS = "status";
	private static final String WOS_clmFAILURE_REASON = "failure_reason";
	private static final String WOS_clmTIMESTAMP = "timestamp";
	private static final String WOS_clmNEEDS_FORWARDING = "needs_forwarding";
	private static final String[] WOS_SELECT_COLS = new String[] {
		WOS_clmID, WOS_clmMESSAGE_ID, WOS_clmSTATUS, WOS_clmFAILURE_REASON, WOS_clmTIMESTAMP };

	private static final String TRUE  = "1";
	private static final String FALSE = "0";

	private static Db _instance;

	private final Context ctx;
	private final SQLiteDatabase db; // NOPMD

	private final ExternalLog external;

	/** a soft limit for the number of log entries to store in the system */
	private int logEntryLimit;
	private String logEntryLimitString;

	public static synchronized Db getInstance(Context ctx) { // NOPMD
		if(_instance == null) {
			_instance = new Db(ctx);
			if(LOAD_SEED_DATA && (FORCE_SEED ||
					_instance.db.compileStatement("SELECT COUNT(*) FROM " + tblLOG).simpleQueryForLong() == 0)) {
				_instance.seed();
			}

			if(DEBUG) _instance.storeLogEntry("Log entries: " + _instance.db.compileStatement("SELECT COUNT(*) FROM " + tblLOG).simpleQueryForLong());
			if(DEBUG) _instance.storeLogEntry("WT messages: " + _instance.db.compileStatement("SELECT COUNT(*) FROM " + tblWT_MESSAGE).simpleQueryForLong());
			if(DEBUG) _instance.storeLogEntry("WT message status updates: " + _instance.db.compileStatement("SELECT COUNT(*) FROM " + tblWT_STATUS).simpleQueryForLong());
			if(DEBUG) _instance.storeLogEntry("WO messages: " + _instance.db.compileStatement("SELECT COUNT(*) FROM " + tblWO_MESSAGE).simpleQueryForLong());
			if(DEBUG) _instance.storeLogEntry("WO message status updates: " + _instance.db.compileStatement("SELECT COUNT(*) FROM " + tblWO_STATUS).simpleQueryForLong());
		}

		return _instance;
	}

	private Db(Context ctx) {
		super(ctx, "medic_gateway", null, SCHEMA_VERSION);
		this.ctx = ctx;
		db = getWritableDatabase();

		external = ExternalLog.getInstance(ctx);

		setLogEntryLimit(200);
	}

	public void onCreate(SQLiteDatabase db) {
		db.execSQL(String.format("CREATE TABLE %s (" +
					"%s INTEGER PRIMARY KEY, " +
					"%s INTEGER NOT NULL, " +
					"%s TEXT NOT NULL)",
				tblLOG, LOG_clmID, LOG_clmTIMESTAMP, LOG_clmMESSAGE));

		db.execSQL(String.format("CREATE TABLE %s (" +
					"%s TEXT NOT NULL PRIMARY KEY, " +
					"%s TEXT NOT NULL, " +
					"%s INTEGER NOT NULL, " +
					"%s TEXT NOT NULL, " +
					"%s TEXT NOT NULL, " +
					"%s INTEGER NOT NULL, " +
					"%s INTEGER NOT NULL)",
				tblWT_MESSAGE, WTM_clmID, WTM_clmSTATUS, WTM_clmLAST_ACTION, WTM_clmFROM, WTM_clmCONTENT, WTM_clmSMS_SENT, WTM_clmSMS_RECEIVED));

		db.execSQL(String.format("CREATE TABLE %s (" +
					"%s TEXT NOT NULL PRIMARY KEY, " +
					"%s TEXT NOT NULL, " +
					"%s TEXT, " +
					"%s INTEGER NOT NULL, " +
					"%s TEXT NOT NULL, " +
					"%s TEXT NOT NULL)",
				tblWO_MESSAGE, WOM_clmID, WOM_clmSTATUS, WOM_clmFAILURE_REASON, WOM_clmLAST_ACTION, WOM_clmTO, WOM_clmCONTENT));

		migrate_createTable_WoMessageStatusUpdate(db, true);
		migrate_createTable_WtMessageStatusUpdate(db, true);
		migrate_createTable_WtMessagePart(db, true);
	}

	public void onUpgrade(SQLiteDatabase db,
			int oldVersion,
			int newVersion) {
		trace(this, "onUpgrade() :: oldVersion=%s, newVersion=%s", oldVersion, newVersion);
		if(oldVersion < 2) {
			migrate_createTable_WoMessageStatusUpdate(db, false);
		}
		if(oldVersion < 3) {
			migrate_create_WOS_clmNEEDS_FORWARDING(db);
		}
		if(oldVersion < 4) {
			migrate_createTable_WtMessageStatusUpdate(db, false);
		}
		if(oldVersion < 5) {
			migrate_create_WTM_clmSMS_SENT__clmSMS_RECEIVED(db);
		}
		if(oldVersion < 6) {
			migrate_createTable_WtMessagePart(db, false);
		}
	}

//> MIGRATIONS
	static void migrate_createTable_WoMessageStatusUpdate(SQLiteDatabase db, boolean isCleanDb) {
		trace(db, "onUpgrade() :: migrate_createTable_WoMessageStatusUpdate()");
		db.execSQL(String.format("CREATE TABLE %s (" +
					"%s INTEGER PRIMARY KEY, " +
					"%s TEXT NOT NULL, " +
					"%s TEXT NOT NULL, " +
					"%s TEXT, " +
					"%s INTEGER NOT NULL, " +
					"%s INTEGER NOT NULL)",
				tblWO_STATUS, WOS_clmID, WOS_clmMESSAGE_ID, WOS_clmSTATUS, WOS_clmFAILURE_REASON, WOS_clmTIMESTAMP, WOS_clmNEEDS_FORWARDING));

		if(!isCleanDb) {
			db.execSQL(String.format("INSERT INTO %s(%s, %s, %s, %s, %s) SELECT %s, %s, %s, %s, %s FROM %s",
					tblWO_STATUS, WOS_clmMESSAGE_ID, WOS_clmSTATUS, WOS_clmFAILURE_REASON, WOS_clmTIMESTAMP, WOS_clmNEEDS_FORWARDING,
							WOM_clmID, WOM_clmSTATUS, WOM_clmFAILURE_REASON, WOM_clmLAST_ACTION, WOM_clmSTATUS_NEEDS_FORWARDING, tblWO_MESSAGE));
		}
	}

	static void migrate_createTable_WtMessageStatusUpdate(SQLiteDatabase db, boolean isCleanDb) {
		trace(db, "onUpgrade() :: migrate_createTable_WtMessageStatusUpdate()");
		db.execSQL(String.format("CREATE TABLE %s (" +
					"%s INTEGER PRIMARY KEY, " +
					"%s TEXT NOT NULL, " +
					"%s TEXT NOT NULL, " +
					"%s INTEGER NOT NULL)",
				tblWT_STATUS, WTS_clmID, WTS_clmMESSAGE_ID, WTS_clmSTATUS, WTS_clmTIMESTAMP));

		if(!isCleanDb) {
			db.execSQL(String.format("INSERT INTO %s(%s, %s, %s) SELECT %s, %s, %s FROM %s",
					tblWT_STATUS, WTS_clmMESSAGE_ID, WTS_clmSTATUS, WTS_clmTIMESTAMP,
							WTM_clmID, WTM_clmSTATUS, WTM_clmLAST_ACTION, tblWT_MESSAGE));
		}
	}

	static void migrate_create_WOS_clmNEEDS_FORWARDING(SQLiteDatabase db) {
		trace(db, "onUpgrade() :: migrate_create_WOS_clmNEEDS_FORWARDING()");
		db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s INTEGER NOT NULL DEFAULT(0)",
				tblWO_STATUS, WOS_clmNEEDS_FORWARDING));
		// copy need_forwarding column values from wo_message into wom_status
		rawUpdateOrDelete(db, "UPDATE %s SET %s=1 WHERE (%s || '_' || %s || '_' || %s) " +
						"IN(SELECT  (%s || '_' || %s || '_' || %s) FROM %s WHERE %s=1)",
				cols(tblWO_STATUS, WOS_clmNEEDS_FORWARDING, WOS_clmMESSAGE_ID, WOS_clmSTATUS, WOS_clmTIMESTAMP,
						WOM_clmID, WOM_clmSTATUS, WOM_clmLAST_ACTION, tblWO_MESSAGE, WOM_clmSTATUS_NEEDS_FORWARDING));

		// We should now drop the status_needs_forwarding column from
		// the wo_message table.  However, dropping columns is not
		// directly supported in SQLite.  There seems little harm in
		// leaving the column in place.
	}

	static void migrate_create_WTM_clmSMS_SENT__clmSMS_RECEIVED(SQLiteDatabase db) {
		trace(db, "onUpgrade() :: migrate_create_WTM_clmSMS_SENT__clmSMS_RECEIVED()");
		db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s INTEGER NOT NULL DEFAULT(0)",
				tblWT_MESSAGE, WTM_clmSMS_SENT));
		db.execSQL(String.format("ALTER TABLE %s ADD COLUMN %s INTEGER NOT NULL DEFAULT(0)",
				tblWT_MESSAGE, WTM_clmSMS_RECEIVED));

		// These values were not stored for old messages, so we can't
		// set a meaningful value for these columns for old messages.
	}

	static void migrate_createTable_WtMessagePart(SQLiteDatabase db, boolean isCleanDb) {
		trace(db, "onUpgrade() :: migrate_createTable_WtMessagePart()");
		db.execSQL(String.format("CREATE TABLE %s (" +
					"%s TEXT NOT NULL, " +
					"%s TEXT NOT NULL, " +
					"%s INTEGER NOT NULL, " +
					"%s INTEGER NOT NULL, " +
					"%s INTEGER NOT NULL, " +
					"%s INTEGER NOT NULL, " +
					"%s INTEGER NOT NULL, " +
					"PRIMARY KEY (%s, %s, %s, %s))",
				tblWT_MESSAGE_PART,
				WMP_clmFROM, WMP_clmCONTENT, WMP_clmSENT, WMP_clmRECEIVED, WMP_clmMP_REF, WMP_clmMP_PART, WMP_clmMP_TOTAL_PARTS,
				WMP_clmFROM, WMP_clmMP_REF, WMP_clmMP_PART, WMP_clmMP_TOTAL_PARTS));
	}

//> ACCESSORS
	void setLogEntryLimit(int limit) {
		logEntryLimit = limit;
		logEntryLimitString = Integer.toString(limit);
	}

//> GENERAL HANDLERS
	int deleteOldData() {
		long oneWeekAgo = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000);

		int totalRecordsDeleted = 0;

		totalRecordsDeleted += db.delete(tblLOG, lt(LOG_clmTIMESTAMP), args(oneWeekAgo));
		totalRecordsDeleted += db.delete(tblWO_MESSAGE, lt(WOM_clmLAST_ACTION), args(oneWeekAgo));
		totalRecordsDeleted += db.delete(tblWT_MESSAGE, lt(WTM_clmLAST_ACTION), args(oneWeekAgo));

		// TODO do we need to VACUUM after deleting?

		return totalRecordsDeleted;
	}

//> GatewayEventLogEntry HANDLERS
	void storeLogEntry(String message) {
		ContentValues v = new ContentValues();
		v.put(LOG_clmTIMESTAMP, System.currentTimeMillis());
		v.put(LOG_clmMESSAGE, message);

		try {
			db.insertOrThrow(tblLOG, null, v);
		} catch(SQLException ex) {
			warnException(ex, "Exception writing log entry to db: %s", message);
		}
	}

	Cursor getLogEntries() {
		return db.query(tblLOG,
				cols(LOG_clmID, LOG_clmTIMESTAMP, LOG_clmMESSAGE),
				ALL, NO_ARGS,
				NO_GROUP, NO_GROUP,
				SortDirection.DESC.apply(LOG_clmID),
				logEntryLimitString);
	}

	void cleanLogs() {
		rawUpdateOrDelete("DELETE FROM %s WHERE %s < (SELECT %s FROM %s LIMIT (SELECT (COUNT(*) - ?) FROM %s),1)",
				cols(tblLOG, LOG_clmID, LOG_clmID, tblLOG, tblLOG),
				args(logEntryLimit));
	}

//> WoMessage HANDLERS
	boolean store(WoMessage m) {
		log("store() :: %s", m);
		try {
			long id = db.insertOrThrow(tblWO_MESSAGE, null, getContentValues(m));

			if(id != -1) {
				storeStatusUpdate(m, m.status, null, m.lastAction, false);
				return true;
			} else {
				return false;
			}
		} catch(SQLiteConstraintException ex) {
			// Likely this is because a message with this ID already exists.  If so,
			// we should update that message so that its status is synched with the
			// server.  This should stop the server from re-sending the same message
			// repeatedly.
			logEvent(ctx, "Message %s appears to be in database already; will be updated.", m);
			return touch(m);
		} catch(SQLException ex) {
			warnException(ex, "Exception writing WoMessage to db: %s", m);
			return false;
		}
	}

	void setFailed(WoMessage m, String failureReason) {
		updateStatus(m, WoMessage.Status.PENDING, WoMessage.Status.FAILED, failureReason);
	}

	boolean updateStatus(WoMessage m, WoMessage.Status newStatus) {
		return updateStatus(m, m.status, newStatus);
	}

	boolean updateStatus(WoMessage m, WoMessage.Status oldStatus, WoMessage.Status newStatus) {
		if(newStatus == WoMessage.Status.FAILED)
			throw new IllegalArgumentException("updateStatus() should not be called with newStatus==FAILED.  Use setFailed().");

		return updateStatus(m, oldStatus, newStatus, null);
	}

	private boolean updateStatus(WoMessage m, WoMessage.Status oldStatus, WoMessage.Status newStatus, String failureReason) {
		log("updateStatus() :: %s :: %s -> %s (%s)", m, oldStatus, newStatus, failureReason);

		if((newStatus == WoMessage.Status.FAILED) == (failureReason == null))
			throw new IllegalArgumentException(String.format(
					"Give failureReason iff new status == FAILED (newStatus=%s, failureReason=%s)",
					newStatus,
					failureReason));

		long timestamp = System.currentTimeMillis();

		ContentValues v = new ContentValues();
		v.put(WOM_clmSTATUS, newStatus.toString());
		v.put(WOM_clmFAILURE_REASON, failureReason);
		v.put(WOM_clmLAST_ACTION, timestamp);

		int affected;
		if(oldStatus == null) {
			affected = db.update(tblWO_MESSAGE, v, eq(WOM_clmID), args(m.id));
		} else {
			affected = db.update(tblWO_MESSAGE, v, eq(WOM_clmID, WOM_clmSTATUS), args(m.id, oldStatus));
		}

		if(affected > 0) {
			storeStatusUpdate(m, newStatus, failureReason, timestamp, true);
			return true;
		} else {
			return false;
		}
	}

	void setStatusForwarded(WoMessage.StatusUpdate u) {
		log("setStatusForwarded() :: %s", u);

		ContentValues v = new ContentValues();
		v.put(WOS_clmNEEDS_FORWARDING, FALSE);

		db.update(tblWO_STATUS, v, eq(WOS_clmID, WOS_clmSTATUS), args(u.id, u.newStatus));
	}

	private boolean touch(WoMessage m) {
		log("touch() :: %s", m);

		ContentValues suV = new ContentValues();
		suV.put(WOS_clmNEEDS_FORWARDING, TRUE);

		Cursor c = null;
		try {
			int affectedRows = rawUpdateOrDelete("UPDATE %s SET %s=? WHERE %s IN (SELECT %s FROM %s WHERE %s=? ORDER BY %s DESC LIMIT 1)",
					cols(tblWO_STATUS, WOS_clmNEEDS_FORWARDING, WOS_clmID, WOS_clmID, tblWO_STATUS, WOS_clmMESSAGE_ID, WOS_clmID),
					args(TRUE, m.id));
			if(affectedRows > 0) {
				ContentValues mV = new ContentValues();
				mV.put(WOM_clmLAST_ACTION, System.currentTimeMillis());
				db.update(tblWO_MESSAGE, mV, eq(WOM_clmID), args(m.id));

				return true;
			} else return false;
		} finally {
			if(c != null) c.close();
		}
	}

	private void storeStatusUpdate(WoMessage m, WoMessage.Status newStatus, String failureReason, long timestamp, boolean needsForwarding) {
		try {
			db.insertOrThrow(tblWO_STATUS, null, getContentValues(m, newStatus, failureReason, timestamp, needsForwarding));
		} catch(SQLException ex) {
			warnException(ex, "Exception writing WO StatusUpdate [%s] to db for WoMessage: %s", newStatus, m);
		}
	}

	private ContentValues getContentValues(WoMessage m) {
		ContentValues v = new ContentValues();
		v.put(WOM_clmID, m.id);
		v.put(WOM_clmSTATUS, m.status.toString());
		v.put(WOM_clmFAILURE_REASON, m.status == WoMessage.Status.FAILED ? m.getFailureReason() : null);
		v.put(WOM_clmLAST_ACTION, System.currentTimeMillis());
		v.put(WOM_clmTO, m.to);
		v.put(WOM_clmCONTENT, m.content);
		return v;
	}

	private ContentValues getContentValues(WoMessage m, WoMessage.Status newStatus, String failureReason, long timestamp, boolean needsForwarding) {
		ContentValues v = new ContentValues();
		v.put(WOS_clmMESSAGE_ID, m.id);
		v.put(WOS_clmSTATUS, newStatus.toString());
		v.put(WOS_clmFAILURE_REASON, failureReason);
		v.put(WOS_clmTIMESTAMP, timestamp);
		v.put(WOS_clmNEEDS_FORWARDING, bool(needsForwarding));
		return v;
	}

	WoMessage getWoMessage(String id) {
		List<WoMessage> matches = getWoMessages(eq(WOM_clmID), args(id), null, 1);
		if(matches.isEmpty()) return null;
		return matches.get(0);
	}

	Cursor getWoMessages(int maxCount) {
		return getWoMessageCursor(null, null, SortDirection.DESC, maxCount);
	}

	List<WoMessage> getWoMessages(int maxCount, WoMessage.Status status) {
		return getWoMessages(eq(WOM_clmSTATUS), args(status), SortDirection.ASC, maxCount);
	}

	List<WoMessage.StatusUpdate> getWoMessageStatusUpdates(int maxCount) {
		Cursor c = null;
		try {
			c = db.query(tblWO_STATUS,
					WOS_SELECT_COLS,
					eq(WOS_clmNEEDS_FORWARDING), args(TRUE),
					NO_GROUP, NO_GROUP,
					DEFAULT_SORT_ORDER,
					Integer.toString(maxCount));

			int count = c.getCount();
			log("getWoMessageStatusUpdates() :: item fetch count: %s", count);
			ArrayList<WoMessage.StatusUpdate> list = new ArrayList<>(count);
			c.moveToFirst();
			while(count-- > 0) {
				list.add(woMessageStatusUpdateFrom(c));
				c.moveToNext();
			}
			return list;
		} finally {
			if(c != null) c.close();
		}
	}

	private List<WoMessage> getWoMessages(String selection, String[] selectionArgs, SortDirection sort, int maxCount) {
		Cursor c = null;
		try {
			c = getWoMessageCursor(selection, selectionArgs, sort, maxCount);

			int count = c.getCount();
			log("getWoMessages() :: item fetch count: %s", count);
			ArrayList<WoMessage> list = new ArrayList<>(count);
			c.moveToFirst();
			while(count-- > 0) {
				list.add(woMessageFrom(c));
				c.moveToNext();
			}
			return list;
		} finally {
			if(c != null) c.close();
		}
	}

	private Cursor getWoMessageCursor(String selection, String[] selectionArgs, SortDirection sort, int maxCount) {
		return db.query(tblWO_MESSAGE,
				cols(WOM_clmID, WOM_clmSTATUS, WOM_clmFAILURE_REASON, WOM_clmLAST_ACTION, WOM_clmTO, WOM_clmCONTENT),
				selection, selectionArgs,
				NO_GROUP, NO_GROUP,
				sort == null? null: sort.apply(WOM_clmLAST_ACTION),
				Integer.toString(maxCount));
	}

	public static WoMessage woMessageFrom(Cursor c) {
		String id = c.getString(0);
		WoMessage.Status status = WoMessage.Status.valueOf(c.getString(1));
		String failureReason = c.getString(2);
		long lastAction = c.getLong(3);
		String to = c.getString(4);
		String content = c.getString(5);

		return new WoMessage(id, status, failureReason, lastAction, to, content);
	}

	private static WoMessage.StatusUpdate woMessageStatusUpdateFrom(Cursor c) {
		long id = c.getLong(0);
		String messageId = c.getString(1);
		WoMessage.Status status = WoMessage.Status.valueOf(c.getString(2));
		String failureReason = c.getString(3);
		long timestamp = c.getLong(4);

		return new WoMessage.StatusUpdate(id, messageId, status, failureReason, timestamp);
	}

	public List<WoMessage.StatusUpdate> getStatusUpdates(WoMessage m) {
		Cursor c = null;
		try {
			c = db.query(tblWO_STATUS,
					WOS_SELECT_COLS,
					eq(WOS_clmMESSAGE_ID), args(m.id),
					NO_GROUP, NO_GROUP,
					DEFAULT_SORT_ORDER,
					NO_LIMIT);

			int count = c.getCount();
			log("getStatusUpdates(WoMessage) :: item fetch count: %s", count);
			ArrayList<WoMessage.StatusUpdate> list = new ArrayList<>(count);
			c.moveToFirst();
			while(count-- > 0) {
				list.add(woMessageStatusUpdateFrom(c));
				c.moveToNext();
			}
			return list;
		} finally {
			if(c != null) c.close();
		}
	}

//> WtMessage HANDLERS
	@SuppressFBWarnings("RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE") // for #117
	boolean store(SmsMessage sms) {
		SmsUdh multi = SmsUdh.from(sms);

		if(multi == null || multi.totalParts == 1) {
			WtMessage m = new WtMessage(
					sms.getOriginatingAddress(),
					sms.getMessageBody(),
					sms.getTimestampMillis());
			return store(m);
		} else {
			try {
				long id = db.insertOrThrow(tblWT_MESSAGE_PART, null, getContentValues(sms, multi));

				if(id == -1) return false;
			} catch(SQLiteConstraintException ex) {
				logException(ex, "Failed to save multipart fragment - it likely already exists in the database.");
				return false;
			}

			Cursor c = null;
			db.beginTransaction();

			try {
				c = db.query(tblWT_MESSAGE_PART,
						cols(WMP_clmCONTENT),
						eq(WMP_clmFROM, WMP_clmMP_REF),
						args(sms.getOriginatingAddress(), multi.multipartRef),
						NO_GROUP, NO_GROUP,
						SortDirection.ASC.apply(WMP_clmMP_PART));
				if(c.getCount() == multi.totalParts) {
					StringBuilder bob = new StringBuilder();
					while(c.moveToNext()) {
						bob.append(c.getString(0));
					}
					boolean success = store(new WtMessage(sms.getOriginatingAddress(), bob.toString(), multi.sentTimestamp));
					if(success) {
						rawUpdateOrDelete("DELETE FROM %s WHERE %s=? AND %s=?",
								cols(tblWT_MESSAGE_PART, WMP_clmFROM, WMP_clmMP_REF),
								args(sms.getOriginatingAddress(), multi.multipartRef));
						db.setTransactionSuccessful();
					} else {
						return false;
					}
				}
				return true;
			} finally {
				db.endTransaction();
				if(c != null) c.close();
			}
		}
	}

	boolean store(WtMessage m) {
		log("store() :: %s", m);
		external.log(m);
		try {
			long id = db.insertOrThrow(tblWT_MESSAGE, null, getContentValues(m));

			if(id != -1) {
				storeStatusUpdate(m, m.getStatus(), m.getLastAction());
				return true;
			} else {
				return false;
			}
		} catch(SQLException ex) {
			warnException(ex, "Exception writing WtMessage to db: %s", m);
			return false;
		}
	}

	boolean storeWithoutLoggingExternally(WtMessage m) {
		log("storeWithoutLoggingExternally() :: %s", m);
		try {
			long id = db.insert(tblWT_MESSAGE, null, getContentValues(m));

			if(id != -1) {
				log("storeWithoutLoggingExternally() :: save successful.");
				storeStatusUpdate(m, m.getStatus(), m.getLastAction());
				return true;
			} else {
				log("storeWithoutLoggingExternally() :: save failed.");
				return false;
			}
		} catch(SQLException ex) {
			warnException(ex, "Exception writing WtMessage to db: %s", m);
			return false;
		}
	}

	void updateStatusFrom(WtMessage.Status oldStatus, WtMessage m) {
		WtMessage.Status newStatus = m.getStatus();
		log("updateStatusFrom() :: %s :: %s -> %s", m, oldStatus, newStatus);

		long timestamp = System.currentTimeMillis();

		ContentValues v = new ContentValues();
		v.put(WTM_clmSTATUS, newStatus.toString());
		v.put(WTM_clmLAST_ACTION, m.getLastAction());

		int affected;
		if(oldStatus == null) {
			affected = db.update(tblWT_MESSAGE, v, eq(WTM_clmID), args(m.id));
		} else {
			affected = db.update(tblWT_MESSAGE, v, eq(WTM_clmID, WTM_clmSTATUS), args(m.id, oldStatus));
		}

		if(affected > 0) {
			storeStatusUpdate(m, newStatus, timestamp);
		}
	}

	private void storeStatusUpdate(WtMessage m, WtMessage.Status newStatus, long timestamp) {
		try {
			db.insertOrThrow(tblWT_STATUS, null, getContentValues(m, newStatus, timestamp));
		} catch(SQLException ex) {
			warnException(ex, "Exception writing WT StatusUpdate [%s] to db for WtMessage: %s", newStatus, m);
		}
	}

	private ContentValues getContentValues(WtMessage m, WtMessage.Status newStatus, long timestamp) {
		ContentValues v = new ContentValues();
		v.put(WTS_clmMESSAGE_ID, m.id);
		v.put(WTS_clmSTATUS, newStatus.toString());
		v.put(WTS_clmTIMESTAMP, timestamp);
		return v;
	}

	private ContentValues getContentValues(WtMessage m) {
		ContentValues v = new ContentValues();
		v.put(WTM_clmID, m.id);
		v.put(WTM_clmSTATUS, m.getStatus().toString());
		v.put(WTM_clmLAST_ACTION, m.getLastAction());
		v.put(WTM_clmFROM, m.from);
		v.put(WTM_clmCONTENT, m.content);
		v.put(WTM_clmSMS_SENT, m.smsSent);
		v.put(WTM_clmSMS_RECEIVED, m.smsReceived);
		return v;
	}

	private ContentValues getContentValues(SmsMessage sms, SmsUdh multi) {
		ContentValues v = new ContentValues();

		v.put(WMP_clmFROM, sms.getOriginatingAddress());
		v.put(WMP_clmCONTENT, sms.getMessageBody());
		v.put(WMP_clmSENT, multi.sentTimestamp);
		v.put(WMP_clmRECEIVED, sms.getTimestampMillis());
		v.put(WMP_clmMP_REF, multi.multipartRef);
		v.put(WMP_clmMP_PART, multi.partNumber);
		v.put(WMP_clmMP_TOTAL_PARTS, multi.totalParts);

		return v;
	}

	WtMessage getWtMessage(String id) {
		List<WtMessage> matches = getWtMessages(eq(WOM_clmID), args(id), null, 1);
		if(matches.isEmpty()) return null;
		return matches.get(0);
	}

	Cursor getWtMessages(int maxCount) {
		return getWtMessageCursor(NO_CRITERIA, NO_ARGS, SortDirection.DESC, maxCount);
	}

	List<WtMessage> getWtMessages(int maxCount, WtMessage.Status status) {
		return getWtMessages(eq(WTM_clmSTATUS), args(status), SortDirection.ASC, maxCount);
	}

	private List<WtMessage> getWtMessages(String selection, String[] selectionArgs, SortDirection sort, int maxCount) {
		Cursor c = null;
		try {
			c = getWtMessageCursor(selection, selectionArgs, sort, maxCount);

			int count = c.getCount();
			log("getWtMessages() :: item fetch count: %s", count);
			ArrayList<WtMessage> list = new ArrayList<>(count);
			c.moveToFirst();
			while(count-- > 0) {
				list.add(new WtMessage(
						c.getString(0),
						WtMessage.Status.valueOf(c.getString(1)),
						c.getLong(2),
						c.getString(3),
						c.getString(4),
						c.getLong(5),
						c.getLong(6)));
				c.moveToNext();
			}
			return list;
		} finally {
			if(c != null) c.close();
		}
	}

	private Cursor getWtMessageCursor(String selection, String[] selectionArgs, SortDirection sort, int maxCount) {
		return db.query(tblWT_MESSAGE,
				cols(WTM_clmID, WTM_clmSTATUS, WTM_clmLAST_ACTION, WTM_clmFROM, WTM_clmCONTENT, WTM_clmSMS_SENT, WTM_clmSMS_RECEIVED),
				selection, selectionArgs,
				NO_GROUP, NO_GROUP,
				sort == null? DEFAULT_SORT_ORDER: sort.apply(WTM_clmLAST_ACTION),
				Integer.toString(maxCount));
	}

	static WtMessage wtMessageFrom(Cursor c) {
		String id = c.getString(0);
		WtMessage.Status status = WtMessage.Status.valueOf(c.getString(1));
		long lastAction = c.getLong(2);
		String from = c.getString(3);
		String content = c.getString(4);
		long smsSent = c.getLong(5);
		long smsReceived = c.getLong(6);

		return new WtMessage(id, status, lastAction, from, content, smsSent, smsReceived);
	}

	private static WtMessage.StatusUpdate wtMessageStatusUpdateFrom(Cursor c) {
		long id = c.getLong(0);
		String messageId = c.getString(1);
		WtMessage.Status status = WtMessage.Status.valueOf(c.getString(2));
		long timestamp = c.getLong(3);

		return new WtMessage.StatusUpdate(id, messageId, status, timestamp);
	}

	public List<WtMessage.StatusUpdate> getStatusUpdates(WtMessage m) {
		Cursor c = null;
		try {
			c = db.query(tblWT_STATUS,
					cols(WTS_clmID, WTS_clmMESSAGE_ID, WTS_clmSTATUS, WTS_clmTIMESTAMP),
					eq(WTS_clmMESSAGE_ID), args(m.id),
					NO_GROUP, NO_GROUP,
					DEFAULT_SORT_ORDER,
					NO_LIMIT);

			int count = c.getCount();
			log("getStatusUpdates(WtMessage) :: item fetch count: %s", count);
			ArrayList<WtMessage.StatusUpdate> list = new ArrayList<>(count);
			c.moveToFirst();
			while(count-- > 0) {
				list.add(wtMessageStatusUpdateFrom(c));
				c.moveToNext();
			}
			return list;
		} finally {
			if(c != null) c.close();
		}
	}

//> DB SEEDING
	private void seed() {
		LogMessages: {
			for(int i=0; i<50; ++i) {
				storeLogEntry("Seed log entry " + i);
			}
		}

		WtMessages: {
			for(int i=0; i<10; ++i) {
				store(new WtMessage("+254789123123", "hello from kenya " + i, i * 3600L * 24L));
				store(new WtMessage("+34678123123", "hello from spain " + i, i * 3600L * 24L));
				store(new WtMessage("+447890123123", "hello from uk " + i, i * 3600L * 24L));
			}

			for(int i=0; i<20; ++i) {
				store(new WtMessage(randomPhoneNumber(), randomSmsContent(), 0));
			}
		}

		WoMessages: {
			for(int i=0; i<10; ++i) {
				store(new WoMessage(randomUUID().toString(), "+254789123123", "hello kenya " + i));
				store(new WoMessage(randomUUID().toString(), "+34678123123", "hello spain " + i));
				store(new WoMessage(randomUUID().toString(), "+447890123123", "hello uk " + i));
			}

			for(int i=0; i<20; ++i) {
				store(new WoMessage(randomUUID().toString(), randomPhoneNumber(), randomSmsContent()));
			}
		}
	}

	private int rawUpdateOrDelete(String statement, String[] cols, String... args) {
		return rawUpdateOrDelete(db, statement, cols, args);
	}

//> MESSAGE REPORT
	@SuppressWarnings("PMD.UseConcurrentHashMap")
	MessageReport generateMessageReport() {
		long womCount = 0;
		long wtmCount = 0;
		Map<Object, Long> statusCounts = new HashMap<>();

		WoMessages: {
			Cursor c = null;
			try {
				c = db.rawQuery("SELECT " + WOM_clmSTATUS + ",COUNT(" + WOM_clmSTATUS + ") FROM " +
								tblWO_MESSAGE + " GROUP BY " + WOM_clmSTATUS,
						args());
				while(c.moveToNext()) {
					WoMessage.Status status = WoMessage.Status.valueOf(c.getString(0));
					long count = c.getLong(1);

					statusCounts.put(status, count);

					womCount += count;
				}
			} finally {
				if(c != null) c.close();
			}
		}

		WtMessages: {
			Cursor c = null;
			try {
				c = db.rawQuery("SELECT " + WTM_clmSTATUS + ",COUNT(" + WTM_clmSTATUS + ") FROM " +
								tblWT_MESSAGE + " GROUP BY " + WTM_clmSTATUS,
						args());
				while(c.moveToNext()) {
					WtMessage.Status status = WtMessage.Status.valueOf(c.getString(0));
					long count = c.getLong(1);

					statusCounts.put(status, count);

					wtmCount += count;
				}
			} finally {
				if(c != null) c.close();
			}
		}

		return new MessageReport(womCount, wtmCount, statusCounts);
	}

//> STATIC HELPERS
	private static String[] cols(String... args) {
		return args;
	}

	private static String lt(String col) { // NOPMD
		return col + "<?";
	}

	private static String eq(String... cols) { // NOPMD
		StringBuilder bob = new StringBuilder();
		for(String col : cols) {
			bob.append(" AND ")
				.append(col)
				.append("=?");
		}
		return bob.substring(5);
	}

	private String bool(boolean value) {
		return value ? TRUE : FALSE;
	}

	private void log(String message, Object... extras) {
		trace(this, message, extras);
	}

	@SuppressWarnings("PMD.UnusedPrivateMethod") // it's used - PMD bug?
	private static int rawUpdateOrDelete(SQLiteDatabase db, String statement, String[] cols, String... args) {
		statement = String.format(statement, (String[]) cols);
		SQLiteStatement s = db.compileStatement(statement);
		for(int i=args.length; i>0; --i)
			s.bindString(i, args[i-1]);
		return s.executeUpdateDelete();
	}
}

enum SortDirection {
	ASC, DESC;

	public String apply(String column) {
		return column + " " + this.toString();
	}
}

class MessageReport {
	final long womCount;
	final long wtmCount;
	private final Map<Object, Long> statusCounts;

	MessageReport(long womCount, long wtmCount, Map<Object, Long> statusCounts) {
		this.womCount = womCount;
		this.wtmCount = wtmCount;
		this.statusCounts = statusCounts;
	}

	public long getCount(WoMessage.Status s) { return getSafely(s); }
	public long getCount(WtMessage.Status s) { return getSafely(s); }

	private long getSafely(Object k) {
		Long val = statusCounts.get(k);
		return val == null ? 0 : val;
	}
}