import datetime

from django.conf import settings
from django.db import models
from django.utils import timezone

def underscore_to_camel_case(string):
	words = [word.capitalize() for word in string.split('_')]
	words[0] = words[0].lower()
	return ''.join(words)

def camel_case_to_underscore(string):
	words = []
	start_index = 0
	for index, c in enumerate(string):
		# Ignore the first character regardless of case
		if c.isupper() and index:
			words.append(string[start_index:index].lower())
			start_index = index
	words.append(string[start_index:].lower())
	return '_'.join(words)

def sanitize_order_by(string):
	"""Make sure the string has no double underscores, also convert from camelcase."""
	if string and string.find('__') == -1 and string.find('?') == -1:
		return camel_case_to_underscore(string)
	return ''

def iso_8601_to_time(iso):
	"""Parse an iso 8601 date into a datetime.time."""
	if not iso:
		return None
	return datetime.datetime.strptime(iso, '%H:%M:%S').time()

def iso_8601_to_date(iso):
	"""Parse an iso 8601 date into a datetime.date."""
	if not iso:
		return None
	return datetime.datetime.strptime(iso[:10], '%Y-%m-%d').date()

def iso_8601_to_datetime(iso):
	"""Parse an iso 8601 string into a timezone aware datetime, ignoring and fractional seconds."""
	if not iso:
		return None
	dt = datetime.datetime.strptime(iso[:19], '%Y-%m-%dT%H:%M:%S')
	# strptime doesn't seem to handle timezones, parse them here
	if len(iso) == 19:
		return timezone.make_aware(dt, timezone.get_current_timezone())
	else:
		# Make the datetime UTC if Z is the timezone, ignoring fractional seconds in between
		if (len(iso) == 20 or iso[19] == '.') and iso[-1] == 'Z':
			return timezone.make_aware(dt, timezone.UTC())
		# Parse a complete timezone e.g. +00:00, checking for the correct length or ignored fractional seconds
		if (len(iso) == 25 or iso[19] == '.') and iso[-6] in ('+', '-') and iso[-3] == ':':
			try:
				hours = int(iso[-5:-3])
				minutes = int(iso[-2:])
				minutes += hours * 60
				if iso[-6] == '-':
					minutes = -minutes
				return timezone.make_aware(dt, timezone.get_fixed_timezone(minutes))
			except:
				# drop through and raise the exception
				pass
		raise ValueError('Invalid timezone %s.' % iso[19:])

def time_to_iso_8601(t):
	"""Format a datetime.time as an iso 8601 string - HH:MM:SS."""
	if t:
		return t.replace(microsecond=0).isoformat()
	else:
		return None

def date_to_iso_8601(d):
	"""Format a datetime.date as an iso 8601 string - YYYY-MM-DD."""
	if d:
		return d.isoformat()
	else:
		return None

def datetime_to_iso_8601(dt):
	"""Format a datetime as an iso 8601 string - YYYY-MM-DDTHH:MM:SS with optional timezone +HH:MM."""
	if dt:
		return dt.replace(microsecond=0).isoformat()
	else:
		return None

def decode_int(value):
	"""Decode an int after checking to make sure it is not already a int, 0.0, or empty."""
	if isinstance(value, (int, long)):
		return value
	elif value == 0.0:
		return 0
	elif not value:
		return None
	return int(value)

def decode_float(value):
	"""Decode a float after checking to make sure it is not already a float, 0, or empty."""
	if type(value) is float:
		return value
	elif value == 0:
		return 0.0
	elif not value:
		return None
	return float(value)

def decode_bool(value):
	"""Decode a bool after checking to make sure it is not already a bool, int, or empty.

	If value is 0.0, None is returned because floats shouldn't be used.
	"""
	t = type(value)
	if t is bool:
		return value
	elif t is int:
		return bool(value)
	elif not value:
		return None
	elif t in (str, unicode) and value.lower() in ('false', 'f', 'off', 'no'):
		return False
	return True

_api_models = {}

class _ApiModel(object):
	def __init__(self, model):
		# Tuples of (name, encoded_name, encode, decode)
		self.fields = []
		self.list_fields = []
		self.encoded_fields = {}
		self.id_field = None
		self.select_related_args = []
		# data dictionary, set fields instead of creating a new dictionary for each get_data
		self._data = {}
		self._list_data = {}
		include_fields = None
		exclude_fields = None
		include_related = ()
		list_fields = None
		update_fields = None
		readonly_fields = None
		camelcase = getattr(settings, 'API_CAMELCASE', True)
		renameid = getattr(settings, 'API_RENAME_ID', True)
		if hasattr(model, 'API'):
			if hasattr(model.API, 'include_fields'):
				include_fields = model.API.include_fields
			if hasattr(model.API, 'exclude_fields'):
				exclude_fields = model.API.exclude_fields
			if hasattr(model.API, 'include_related'):
				include_related = model.API.include_related
			if hasattr(model.API, 'list_fields'):
				list_fields = model.API.list_fields
			if hasattr(model.API, 'update_fields'):
				update_fields = model.API.update_fields
			if hasattr(model.API, 'readonly_fields'):
				readonly_fields = model.API.readonly_fields

		# Calculate all of the fields and list fields
		for field in model._meta.fields:
			if include_fields and field.name not in include_fields:
				continue
			elif exclude_fields and field.name in exclude_fields:
				continue
			else:
				name = field.name
				if field.name == 'id' and renameid:
					encoded_name = camel_case_to_underscore(model.__name__) + '_id'
				else:
					encoded_name = field.name
				if isinstance(field, models.IntegerField):
					encode = None
					decode = decode_int
				elif isinstance(field, models.FloatField):
					encode = None
					decode = decode_float
				elif isinstance(field, models.BooleanField):
					encode = None
					decode = decode_bool
				elif isinstance(field, models.DateTimeField):
					encode = datetime_to_iso_8601
					decode = iso_8601_to_datetime
				elif isinstance(field, models.TimeField):
					encode = time_to_iso_8601
					decode = iso_8601_to_time
				elif isinstance(field, models.DateField):
					encode = date_to_iso_8601
					decode = iso_8601_to_date
				elif isinstance(field, models.ForeignKey):
					if field.name in include_related:
						encode = get_object_data
						decode = set_object_data
						# Calculate the select_related_args
						related_model = _get_api_model(field.rel.to)
						if related_model.select_related_args:
							for arg in related_model.select_related_args:
								self.select_related_args.append('%s__%s' % (field.name, arg))
						else:
							self.select_related_args.append(field.name)
						# For include related fields, also add an encoded_field entry for the option of updating the foreign key to another entry
						# Setting the name_id attribute to None has the same effect as setting name to None, it will set the foreign key to null in the db
						field_coding = (name + '_id', underscore_to_camel_case(encoded_name + '_id') if camelcase else encoded_name + '_id', None, decode_int)
						self.encoded_fields[field_coding[1]] = field_coding
					else:
						name += '_id'
						encoded_name += '_id'
						encode = None
						decode = decode_int
						# For pointers to parent models, add as readonly field without the ptr suffix
						if isinstance(field, models.OneToOneField) and encoded_name.endswith('_ptr_id'):
							encoded_name = encoded_name[:-7] + '_id'
							field_coding = (name, underscore_to_camel_case(encoded_name) if camelcase else encoded_name, None, decode_int)
							self.fields.append(field_coding)
							self.list_fields.append(field_coding)
							continue
				elif isinstance(field, models.AutoField):
					encode = None
					decode = decode_int
				elif isinstance(field, (models.FileField, models.ManyToManyField)):
					continue
				else:
					encode = None
					decode = None

				if camelcase:
					field_coding = (name, underscore_to_camel_case(encoded_name), encode, decode)
				else:
					field_coding = (name, encoded_name, encode, decode)
				self.fields.append(field_coding)
				if field.name == 'id':
					self.id_field = field_coding
				if not field.editable or field.primary_key:
					pass
				elif update_fields and field.name not in update_fields:
					pass
				elif readonly_fields and field.name in readonly_fields:
					pass
				else:
					self.encoded_fields[field_coding[1]] = field_coding
				if list_fields is None or field.name in list_fields:
					if field_coding[2] == get_object_data:
						self.list_fields.append((field_coding[0], field_coding[1], get_object_list_data, field_coding[3]))
					else:
						self.list_fields.append(field_coding)

	def get_list_data(self, obj):
		for name, encoded_name, encode, decode in self.list_fields:
			if encode:
				self._list_data[encoded_name] = encode(getattr(obj, name))
			else:
				self._list_data[encoded_name] = getattr(obj, name)
		return self._list_data

	def get_data(self, obj):
		for name, encoded_name, encode, decode in self.fields:
			if encode:
				self._data[encoded_name] = encode(getattr(obj, name))
			else:
				self._data[encoded_name] = getattr(obj, name)
		return self._data

	def set_data(self, obj, data):
		for key, value in data.iteritems():
			if self.encoded_fields.has_key(key):
				name, encoded_name, encode, decode = self.encoded_fields[key]
				if decode:
					if decode is set_object_data:
						decode(getattr(obj, name), value)
					else:
						setattr(obj, name, decode(value))
				else:
					setattr(obj, name, value)

def _get_api_model(model):
	key = model.__module__ + model.__name__
	if not _api_models.has_key(key):
		_api_models[key] = _ApiModel(model)
	return _api_models[key]

def get_object_list_data(obj):
	if obj is None:
		return None
	model = _get_api_model(type(obj))
	return model.get_list_data(obj)

def get_object_data(obj):
	if obj is None:
		return None
	model = _get_api_model(type(obj))
	data = model.get_data(obj)
	if hasattr(obj, '_exclude_data'):
		# Return a copy with specific data fields removed
		data = dict(data)
		for excluded in obj._exclude_data:
			del data[excluded]
	return data

def set_object_data(obj, data):
	model = _get_api_model(type(obj))
	model.set_data(obj, data)

def save_object(obj):
	model = type(obj)
	if hasattr(model, 'API') and hasattr(model.API, 'include_related'):
		for field in model.API.include_related:
			# Do a quick check for readonly status - this is for a slight performance boost only
			# There are other settings that can cause the sub-object to be readonly, but it's not enforced here
			if hasattr(model.API, 'readonly_fields') and field in model.API.readonly_fields:
				continue
			subobj = getattr(obj, field, None)
			if subobj:
				save_object(subobj)
	obj.full_clean()
	obj.save()
