"""A catalog for Python objects"""

import MySQLdb
from mx import DateTime
import types
import string
# FIXME consider breaking queryutils apart in an independent module so it can be reused with MyCatalog
from query import *
import logging
# import os

def is_valid_attribute_name(attrname):
	if len(attrname) == 0: return False
	if len(attrname) > 29: return False
	for a in attrname:
		if a.lower() not in "abcdefghijklmnopqrstuvwxyz_":
			return False
	if attrname[0] == "_": return False
	return True
	
def q(x):
	typex = type(x)
	if typex == DateTime.DateTimeType:
		x = str(x)
	if isinstance(x, types.StringType):
		x = "'" + string.replace(string.replace(str(x), '\\', '\\\\'), "'", "''") + "'"
	elif typex in (types.IntType, types.LongType, types.FloatType):
		pass
	elif x is None:
		x = 'NULL'
	elif x is True:
		x = 'TRUE'
	elif x is False:
		x = 'FALSE'
	elif typex in (types.ListType, types.TupleType):
		x = '(%s)' % string.join(map(lambda x: str(q(x)), x), ',')
	elif hasattr(x, '__pg_repr__'):
		x = x.__pg_repr__()
	else:
		raise ValueError, 'do not know how to handle type %s' % typex
	return x


map_types = {
	int:"int"
	,float:"float"
	,str:["str"]#,"fts"]
	,types.NoneType:"none" # FIX none searches, they are not working right now
	,bool:"boolean"
	,DateTime.DateTimeType:"datetime"
}

map_sqltypes = {
	"boolean":"BOOL"
	,"int":"INTEGER"
	,"str":"TEXT"
# 	,"fts":"tsvector"
	,"float":"FLOAT"
	,"datetime":"TIMESTAMP"
}

map_indexes = {
	"boolean":{"name":"(name)","value":"(value)"}
	,"int":{"name":"(name)","value":"(value)"}
	,"str":{"name":"(name)","value":"(value(255))","fts":"(value)"}
# 	,"fts":{"name":"(name)","value":"using gist (value)"}
	,"float":{"name":"(name)","value":"(value)"}
	,"datetime":{"name":"(name)","value":"(value)"}
	,"none":{"name":"(name)"}
}

map_charsets = {
"utf8":"UNICODE"
,"iso88591":"LATIN1"
,"iso88592":"LATIN2"
,"iso88593":"LATIN3"
,"iso88594":"LATIN4"
,"iso88599":"LATIN5"
,"iso885910":"LATIN6"
,"iso885913":"LATIN7"
,"iso885914":"LATIN8"
,"iso885915":"LATIN9"
,"iso88595":"ISO_8859_5"
,"iso88596":"ISO_8859_6"
,"iso88597":"ISO_8859_7"
,"iso88598":"ISO_8859_8"
}

def get_equivalent_pg_encoding(python_encoding):
	assert type(python_encoding) is str and len(python_encoding) > 0
	charset = python_encoding
	charset = charset.replace("-","")
	charset = charset.lower()
	try: charset = map_charsets[charset]
	except KeyError: raise LookupError, "Unknown PostgreSQL encoding for Python encoding %s"%python_encoding
	return charset

def get_preferred_pg_encoding():
	import locale
	return get_equivalent_pg_encoding(locale.getpreferredencoding())


class MyCatalog:
	"""The MyCatalog is a MySQL-based catalog which indexes objects'
	attributes and allows for fast querying at a later stage.
	
	Each catalog has several infospaces."""
	name = None
	
	def __init__(self,**dbargs):
		"""Instantiates a catalog, based on the passed database
		arguments.  Check the MySQLdb.connect documentation to see
		supported arguments.""" #FIXME this docstring
		self.__update_database_info(**dbargs)
		self.log = logging.getLogger("MyCatalog."+self.name)
		
	def update_schema(self):
		pass # for future migrations

	def close(self):
		"""Closes the catalog.  All infospace connections need to be
		closed individually."""
		pass
		
	def __update_database_info(self,**dbargs):
# 		assert type(server) is str and len(server) > 0
		self.name = dbargs["db"]
		self.dbargs = dbargs
	
	def __check_db_schema(self):
		pass
	
	def get_catalog_infospace(self,name):
		"""Returns a MyCatalogInfospace representing a particular
		infospace, creating it if necessary."""
		if not hasattr(self,"catalog_infospaces"): self.catalog_infospaces = {}
		if not self.catalog_infospaces.has_key(name):
			self.catalog_infospaces[name] = MyCatalogInfospace(self,name)
		return self.catalog_infospaces[name]

	def _conn(self):
		c = MySQLdb.connect(**self.dbargs)
		return c # FIXME encoding
		cursor = c.cursor()
		encoding = get_preferred_pg_encoding()
		sql = "SET CLIENT_ENCODING TO '%s'"%encoding;
		cursor.execute(sql)
		cursor.close
		cursor = c.cursor()
		cursor.execute("SHOW client_encoding")
		results = str(cursor.fetchone()[0])
		self.log.debug("Connection client encoding set to %s",results)
		
		# little stub to select the tsearch config automatically upon connection
		sql = "SELECT * from pg_class where relname = '%s'"%"pg_ts_cfg"
		cursor.execute(sql)
		results = cursor.fetchone()
		if results:
			sql = "select set_curcfg('default');"
			cursor.execute(sql)
		
		cursor.close()
		return c
		
	def pack(self):
		"""Equivalent to self.optimize()"""
		self.optimize()
		
	def optimize(self):
		"""Vacuums and analyzes the database for faster searching.
		It takes a long time to finish."""
		pass


class MyCatalogInfospace:
	"""A catalog infospace.  Do not instantiate directly, but use the
	get_catalog_infospace(name) method of MyCatalog."""
	catalog = None
	name = None #The name will be used as the table prefix
	
	def __init__(self,catalog,name):
		"""Do not instantiate this class directly."""
		assert type(name) is str and len(name) > 0
		assert isinstance(catalog,MyCatalog) #
		self.catalog = catalog
		self.name = name
		self.log = logging.getLogger("MyCatalog."+self.catalog.name+"."+self.name)
		self.__check_db_schema()

	def _new_db_connection(self):
		return self.catalog._conn()
		
	def _get_correct_table_name(self,datatype):
		return "%s_property_%s"%(self.name,datatype)
		
	def __check_db_schema(self):
		connection = self._new_db_connection()
		cursor = connection.cursor()
		tables = map_sqltypes
		indexes = map_indexes
		
		sql = "show tables;"
		cursor.execute(sql)
		existing_tables = [ a[0] for a in cursor.fetchall() ]

		mastertablename = self._get_correct_table_name("master")
		if mastertablename not in existing_tables:
			sql = "CREATE TABLE %s (objectid INT NOT NULL AUTO_INCREMENT, objectkey TEXT NOT NULL, PRIMARY KEY(objectid));"%(mastertablename)
			self.log.debug("Creating master table: %s",sql)
			cursor.execute(sql)
		
# 		masteridxname = mastertablename + "_idx"
# 		sql = "SELECT * from pg_class where relname = '%s'"%masteridxname
# 		cursor.execute(sql)
# 		results = cursor.fetchone()
# 		if not results:
# 			sql = "CREATE UNIQUE INDEX %s on %s (key);"%(masteridxname,mastertablename)
# 			self.log.debug("Creating master table key index: %s",sql)
# 			cursor.execute(sql)
		
		for datatype,datatypenamelist in map_types.iteritems():
			if type(datatypenamelist) is not list: datatypenamelist = [ datatypenamelist ]
			for datatypename in datatypenamelist:
				tablename = self._get_correct_table_name(datatypename)
# 				sql = "SELECT * from pg_class where relname = '%s'"%tablename
# 				cursor.execute(sql)
# 				results = cursor.fetchone()
				if not tablename in existing_tables:
					if tables.has_key(datatypename):
						sqldatatype = tables[datatypename]
						sql = "CREATE TABLE %s (objectid INT NOT NULL,name VARCHAR(255) NOT NULL,value %s NOT NULL, PRIMARY KEY(objectid,name), FOREIGN KEY (objectid) REFERENCES %s ON DELETE CASCADE);"%(tablename,sqldatatype,mastertablename)
					else:
						sql = "CREATE TABLE %s (objectid INT NOT NULL,name VARCHAR(255) NOT NULL, PRIMARY KEY(objectid,name), FOREIGN KEY (objectid) REFERENCES %s ON DELETE CASCADE);"%(tablename,mastertablename)
					self.log.debug("Creating table for %s: %s",datatypename,sql)
					cursor.execute(sql)
		
		for datatypename,expression in indexes.iteritems():
			for column,indexexpression in expression.iteritems():
				tablename = self._get_correct_table_name(datatypename)
				indexname = column + "_idx"
				sql = "SHOW INDEX FROM %s"%tablename
				cursor.execute(sql)
				results = cursor.fetchall()
				found = False
				for a in results:
					if a[2] == indexname: found = True
				if not found:
					if column == "fts": fulltext = "FULLTEXT"
					else: fulltext = ""
					sql = "CREATE %s INDEX %s on %s %s;"%(fulltext,indexname,tablename,indexexpression)
					self.log.debug("Creating index %s: %s",indexname,sql)
					cursor.execute(sql)
		
		connection.commit()
		cursor.close()
		connection.close()

	def conn(self):
		"""Returns a connection to the infospace (of instance
		MyCatalogInfospaceConnection."""
		return MyCatalogInfospaceConnection(self)

class MyCatalogInfospaceConnection:
	"""A connection to a catalog infospace. You should use this directly
	to catalog objects, uncatalog them and perform searches.  After
	using it, close() the connection or risk data loss.
	
	To store an object, begin_transaction(), use the catalogObject()
	method and commit_transaction().  To uncatalog an object, use the
	uncatalogObject(), beginning/endding the transaction as well.
	
	To search, use the search() method, passing an Expression."""
	
	catalog_infospace = None
	
	def __init__(self,catalog_infospace):
		"""Do not instantiate directly.  Use the conn() method of
		MyCatalogInfospace."""
		self.catalog_infospace = catalog_infospace
		self.connection = self.catalog_infospace._new_db_connection()
		self.log = logging.getLogger("MyCatalog."+self.catalog_infospace.catalog.name+
				"."+self.catalog_infospace.name)

	
	def close(self):
		"""Abandon and release the connection to the database once
		you're done using it."""
		self.connection.close()
		
	def catalogObject(self,object_key,object):
		"""Catalog an object under the name object_key."""
		assert type(object_key) is str and len(object_key) > 0
		assert isinstance(object,Catalogable)
		assert self.connection is not None
		self.uncatalogObject(object_key)
		cursor = self.connection.cursor()
		
		tablename = self.catalog_infospace._get_correct_table_name("master")
		object_key_quoted = q(object_key)
		tk = (tablename,object_key_quoted)
		sql = "INSERT INTO %s (objectkey) VALUES(%s);" %tk
		try: cursor.execute(sql)
		except:
			logging.getLogger("MyCatalog."+self.catalog_infospace.name).error("Error on SQL query: %s",sql)
			cursor.close()
			raise
		
		sql = "SELECT LAST_INSERT_ID();"
		try:
			cursor.execute(sql)
			results = cursor.fetchone()
			objectid = results[0]
		except:
			logging.getLogger("MyCatalog."+self.catalog_infospace.name).error("Error on SQL query: %s",sql)
			cursor.close()
			raise
		
		cats = object.get_catalogable_attributes()
		for attr in cats:
			if not hasattr(object,attr): continue
			value = getattr(object,attr)
			t = type(value)
			if not map_types.has_key(t): continue
			
			thetypes = map_types[t]
			if type(thetypes) is not list: thetypes = [ thetypes ]
			for thetype in thetypes:
				tablename = self.catalog_infospace._get_correct_table_name(thetype)
				if t is types.NoneType:
					tkn = (tablename,objectid,q(attr))
					sql = "INSERT INTO %s (objectid,name) VALUES(%s,%s);" %tkn
				elif t is str:
					tknv = (tablename,objectid,q(attr),q(value))
					sql = "INSERT INTO %s (objectid,name,value) VALUES(%s,%s,%s);" %tknv
				else:
					tknv = (tablename,objectid,q(attr),q(value))
					sql = "INSERT INTO %s (objectid,name,value) VALUES(%s,%s,%s);" %tknv
				try:
# 					self.log.debug("SQL query: %s",sql)
					cursor.execute(sql)
				except:
					self.log.error("Error on SQL query: %s",sql)
					cursor.close()
					raise
		cursor.close()
	
	def uncatalogObject(self,object_key):
		"""Remove an object from the catalog."""
		assert type(object_key) is str and len(object_key) > 0
		assert self.connection is not None
		cursor = self.connection.cursor()
		tablename = self.catalog_infospace._get_correct_table_name("master")
# 		sql = "DELETE FROM %s where objectkey = %s;" %(tablename,q(object_key))
		sql = "SELECT objectid FROM %s where objectkey = %s;" %(tablename,q(object_key))
# 		self.log.debug("SQL query: %s",sql)
		try:
			cursor.execute(sql)
			retrieved = cursor.fetchone()
			if not retrieved:
				cursor.close()
				return
			object_id = int(retrieved[0])
		except:
			self.log.error("Error on SQL query: %s",sql)
			cursor.close()
			raise
		
			
		for datatype,datatypenamelist in map_types.iteritems():
			if type(datatypenamelist) is not list: datatypenamelist = [ datatypenamelist ]
			for datatypename in datatypenamelist:
				tablename = self.catalog_infospace._get_correct_table_name(datatypename)
				sql = "DELETE FROM %s where objectid = %s;"%(tablename,q(object_id))
				try: cursor.execute(sql)
				except:
					self.log.error("Error on SQL query: %s",sql)
					cursor.close()
					raise
		
		sql = "DELETE FROM %s where objectid = %s;" %(tablename,q(object_id))
		tablename = self.catalog_infospace._get_correct_table_name("master")
		self.log.debug("SQL query: %s",sql)
		try:
			cursor.execute(sql)
		except:
			self.log.error("Error on SQL query: %s",sql)
			cursor.close()
			raise
		
		
		cursor.close()
	
	def begin_transaction(self):
		"""Begins a transaction.  Any modifications done in the
		context of a transaction will not be saved until the
		transaction has been committed."""
		pass
		
	def commit_transaction(self):
		"""Commit a transaction, saving all the changes since the
		last begin_transaction()."""
		self.connection.commit()
	
	def abort_transaction(self):
		"""Roll back all changes since the last begin_transaction()."""
		try: self.connection.rollback()
		except: pass
	
	def search(self,expression):
		"""Search for objects matching a passed Expression. See the
		mytools.query module documentation for more information."""
		assert self.connection is not None
		
		expression = optimize(expression)
		self.log.debug("Optimized expression: %s",expression)
		return self._search(expression)
	
	def _search(self,expression,depth=0,group_ands=True):
	
		self.log.debug("Depth %s: Executing expression: %s",depth,expression)
		if not self.has_any_ORs(expression):
		
			sql = self._convert_to_sql(expression)
			
			self.log.debug("Equivalent SQL query: %s",sql)
			
			cursor=self.connection.cursor()
			cursor.execute(sql)
			results = {}
			for a in cursor.fetchall(): results[a[1]] = float(a[0])
			cursor.close()
			
			return ResultSet(results)

		# else...
		
		pureandexps = []
		otherexps = []
		for exp in expression.expressions:
			if not self.has_any_ORs(exp) and group_ands: pureandexps.append(exp)
			else: otherexps.append(exp)
		
		if pureandexps:
			exp = expression.__class__(pureandexps)
			results = self._search(exp,group_ands=False,depth=depth+1)
			first_one = False
		else:
			results = ResultSet()
			first_one = True
		
		for exp in otherexps:
			if isinstance(expression,Or): results.union_update(self._search(exp,depth=depth+1))
			elif isinstance(expression,And):
				if first_one:
					results = self._search(exp,depth=depth+1)
					first_one = False
				else:
					results.intersection_update(self._search(exp,depth=depth+1))
			else:
				raise AssertionError, "Reached something we should not have"
		return results

	
	def has_any_ORs(self,expression):
	
		if not isinstance(expression,Aggregation): return False
		
		
		if isinstance(expression,Or): return True
		for e in expression.expressions:
			if self.has_any_ORs(e): return True
		
		return False

	def _convert_to_sql(self,expression):
	
		assert isinstance(expression,Expression)
		
		involved_where = self.get_involved_where(expression)
		regexp = "MATCH \(.*\) AGAINST \('.*'\)"
		import re
		matches = re.findall(regexp,involved_where)
		if len(matches) > 1: raise NotImplementedError, "Weighting by two or more search attributes is not implemented"
		if matches: weight_column = matches[0] + " AS score"
		else: weight_column = "1 as score"
		
		mastertable = self.catalog_infospace._get_correct_table_name("master")
		sql = "SELECT %s,_master.objectkey from %s as _master" % (weight_column,mastertable)
		
		involved_tables = self.get_involved_tables(expression)
		for key,val in involved_tables.iteritems():
			d = {}
			d["master"] = "_master"
			d["attrname"] = q(key)
			d["tablename"] = key+"_"+val
			d["tabletype"] = self.catalog_infospace._get_correct_table_name(val)
			sql += " INNER JOIN %(tabletype)s AS %(tablename)s ON (%(master)s.objectid = %(tablename)s.objectid AND %(tablename)s.name = %(attrname)s )"%d
		
		if type(involved_where) is str: involved_where = involved_where.strip()
		if involved_where: sql += " WHERE " + involved_where;
		
		return sql
	
	def get_involved_tables(self,expression):
		if isinstance(expression,Criterion):
			# choose the right table based on the data types
			typex = type(expression.value)
			try: tname = map_types[typex]
			except KeyError: raise NotImplementedError, "%s queries for %s not available"%(str(expression.operator),str(typex))
			if "str" in tname: tname = "str"
			# temp fixup until fts searching is fully implemented
			# maybe we should clean this shit up right?
			
			if not is_valid_attribute_name(expression.attribute):
				raise ValueError, "Invalid attribute name: %s"%expression.attribute
			return {expression.attribute:tname}
		
		elif isinstance(expression,Aggregation):
			mylist = {}
			for a in expression.expressions:
				for b,c in self.get_involved_tables(a).iteritems(): mylist[b] = c
			return mylist
			
		raise AssertionError, "This should not have been reached"

	def get_involved_where(self,expression):
		
		if isinstance(expression,Aggregation):
		
			sqls = [ self.get_involved_where(e) for e in expression.expressions ]
			sqls = [ "(" + e + ")" for e in sqls if e ]
			if isinstance(expression,And): sep = " AND "
			elif isinstance(expression,Or): sep = " OR "
			else: raise Exception, "What the hell?"
			
			if len(sqls): sql = "%s" % (sep.join(sqls))
			else: sql = ""
			
			return sql
		
		elif isinstance(expression,Criterion):
			
			# choose the right table based on the data types
			typex = type(expression.value)
			try: tname = map_types[typex]
			except KeyError: raise NotImplementedError, "%s queries for %s not available"%(str(expression.operator),str(typex))
			if "str" in tname: tname = "str"
			# temp fixup until fts searching is fully implemented
			# maybe we should clean this shit up right?
			
			if not is_valid_attribute_name(expression.attribute):
				raise ValueError, "Invalid attribute name: %s"%expression.attribute
			
			pbag = {}
			pbag["right_table"] = expression.attribute + "_" + tname
			
			opclass = expression.operator.__class__
			
			sql = None
			if typex is types.NoneType:
				raise NotImplementedError, "None queries not implemented" # FIXME
				if opclass is Equals:
					sql = "" # This should make this function return "" thus making it work like a charm because there will be no where clause
			elif typex is bool:
				if opclass is Equals:
					sql = "%(right_table)s.value = %(value)s"
			elif typex in [int,float,DateTime.DateTimeType]:
				pmap = {
					Equals:"%(right_table)s.value = value = %(value)s"
					,IsEqualOrLessThan:"%(right_table)s.value <= %(value)s"
					,IsEqualOrGreaterThan:"%(right_table)s.value >= %(value)s"
					,IsLessThan:"%(right_table)s.value < %(value)s"
					,IsGreaterThan:"%(right_table)s.value > %(value)s"
				}
				if pmap.has_key(opclass): sql=pmap[opclass]
			elif typex is str:
				### special case LIKE queries.  special case NL queries. special case Icase queries. FIXME
# 				if len(expression.value) < 2714: pbag["predicate"] =  "and length(value) < 2714"
# 				else:
				pbag["predicate"] = "" # should we remove this cruft?
				pmap = {
					Equals:"%(right_table)s.value = %(value)s %(predicate)s",
					Contains:"%(right_table)s.value LIKE %(p1)s %(predicate)s",
					MatchesGlobExp:"%(right_table)s.value LIKE %(p1)s %(predicate)s",
					IsBelow:"%(right_table)s.value LIKE %(p1)s %(predicate)s",
					IsOrIsBelow:"(%(right_table)s.value LIKE %(p1)s %(predicate)s) OR (%(right_table)s.value = %(value)s %(predicate)s)",
					NL:"MATCH (%(right_table)s.value) AGAINST (%(value)s) %(predicate)s",
					}
				if pmap.has_key(opclass): sql=pmap[opclass]

				wescapedev = expression.value.replace("\\","\\\\").replace("%","\\%").replace("_","\\_")
				if opclass in [ IsBelow , IsOrIsBelow ]:
#should we?:	expression.value = os.path.normpath(expression.value)
					pbag["p1"] = q(wescapedev + "/%")
				if opclass is Contains:
					pbag["p1"] = q("%" + wescapedev + "%")
				if opclass is MatchesGlobExp:
					pbag["p1"] = q(wescapedev.replace("*","%").replace("?","_"))
				if opclass is MatchesRegExp:
					pass # just for symmetry, because above the sql is already ready
			
			if sql is None:
				raise NotImplementedError, "%s queries for %s not available"% (str(expression.operator),str(typex))
				
			pbag["value"] = q(expression.value)
			sql = sql%pbag

			return sql
		
		else: raise Exception, "What the hell?"


class Catalogable:
	"""Represents a catalogable object.  All objects to be catalogued need
	to inherit from Catalogable, and have an attribute which describes
	which attributes to catalog:

	class Stuff(Catalogable):
		a = "a"
		b = 2
		Stuff_catalogable = ["a","b"]

	Catalogable attributes can only be:
	* strings
	* integers
	* floats
	* booleans
	* mx.DateTime
	* None"""
	def get_catalogable_attributes(self):
		attrs = []
		for key in dir(self):
			if key.endswith("_catalogable"):
				value = getattr(self,key)
				for attrname in value:
					if attrname not in attrs: attrs.append(attrname)
		return attrs
		

