# stackcore.py - JSONModel/Enumeration + other utility classes that don't really belong now that the API's multi-file
# This file is relatively safe to "import *"

import datetime, urllib2
from math import floor

## JSONModel base class
class JSONModel(object):
	"""The base class of all the objects which describe API objects directly - ie, those which take JSON objects as parameters to their constructor."""

	def __init__(self, json, site, skip_ext=False):
		self.json = json
		self.json_ob = DictObject(json)
		self.site = site

		for f in self.transfer:
			if hasattr(self.json_ob, f):
				setattr(self, f, getattr(self.json_ob, f))

		if hasattr(self, '_extend') and not skip_ext:
			self._extend(self.json_ob, site)

	def fetch(self):
		"""Fetches all the data that the model can describe, not just the attributes which were specified in the original response."""
		if hasattr(self, 'fetch_callback'):
			res = self.fetch_callback(self, self.site)

			if isinstance(res, dict):
				self.__init__(res, self.site)
			elif hasattr(res, 'json'):
				self.__init__(res.json, self.site)
			else:
				raise ValueError('Supplied fetch callback did not return a usable value.')
		else:
			return False

	# Allows the easy creation of updateable, partial classes
	@classmethod
	def partial(cls, fetch_callback, site, populate):
		"""Creates a partial description of the API object, with the proviso that the full set of data can be fetched later."""

		model = cls({}, site, True)

		for k, v in populate.iteritems():
			setattr(model, k, v)

		model.fetch_callback = fetch_callback
		return model

	# for use with Lazy classes that need a callback to actually set the model property
	def _up(self, a):
		"""Returns a function which can be used with the LazySequence class to actually update the results properties on the model with the
new fetched data."""

		def inner(m):
			setattr(self, a, m)
		return inner

class Enumeration(object):
	"""Provides a base class for enumeration classes. (Similar to 'enum' types in other languages.)"""

	@classmethod
	def from_string(cls, text, typ=None):
		'Returns the appropriate enumeration value for the given string, mapping underscored names to CamelCase, or the input string if a mapping could not be made.'
		if typ is not None:
			if hasattr(typ, '_map') and text in typ._map:
				return getattr(typ, typ._map[text])
			elif hasattr(typ, text[0].upper() + text[1:]):
				return getattr(typ, text[0].upper() + text[1:])
			elif '_' in text:
				real_name = ''.join(x.title() for x in text.split('_'))
				if hasattr(typ, real_name):
					return getattr(typ, real_name)
				else:
					return text
			else:
				return text
		else:
			return cls.from_string(text, cls)

class StackExchangeError(Exception):
	"""A generic error thrown on a bad HTTP request during a StackExchange API request."""
	UNKNOWN = -1

	def __init__(self, code = UNKNOWN, name = None, message = None):
		self.code = code
		self.name = name
		self.message = message
	
	def __str__(self):
		if self.code == self.UNKNOWN:
			return 'unrecognised error'
		else:
			return '%d [%s]: %s' % (self.code, self.name, self.message)


class StackExchangeResultset(tuple):
	"""Defines an immutable, paginated resultset. This class can be used as a tuple, but provides extended metadata as well, including methods
to fetch the next page."""

	def __new__(cls, items, build_info, has_more = True, page = 1, pagesize = None):
		if pagesize is None:
			pagesize = len(items)

		instance = tuple.__new__(cls, items)
		instance.page, instance.pagesize, instance.build_info = page, pagesize, build_info
		instance.items = items
		instance.has_more = has_more

		return instance

	def reload(self):
		"""Refreshes the data in the resultset with fresh API data. Note that this doesn't work with extended resultsets."""
		# kind of a cheat, but oh well
		return self.fetch_page(self.page)

	def fetch_page(self, page, **kw):
		"""Returns a new resultset containing data from the specified page of the results. It re-uses all parameters that were passed in
to the initial function which created the resultset."""
		new_params = list(self.build_info)
		new_params[4] = new_params[4].copy()
		new_params[4].update(kw)
		new_params[4]['page'] = page

		new_set = new_params[0].build(*new_params[1:])
		new_set.page = page
		return new_set

	def fetch_extended(self, page):
		"""Returns a new resultset containing data from this resultset AND from the specified page."""
		next = self.fetch_page(page)
		extended = self + next

		# max(0, ...) is so a non-zero, positive result for page is always found
		return StackExchangeResultset(extended, self.build_info, next.has_more, page)

	def fetch_next(self):
		"""Returns the resultset of the data in the next page."""
		return self.fetch_page(self.page + 1)

	def extend_next(self):
		"""Returns a new resultset containing data from this resultset AND from the next page."""
		return self.fetch_extended(self.page + 1)

	def fetch(self):
		# Do nothing, but allow multiple fetch calls
		return self

	def __iter__(self):
		return self.next()

	def next(self):
		for obj in self.items:
			yield obj

		current = self
		while current.has_more:
			for obj in current.items:
				yield obj

			try:
				current = current.fetch_next()
				if len(current) == 0:
					return
			except urllib2.HTTPError:
				return

class NeedsAwokenError(Exception):
	"""An error raised when an attempt is made to access a property of a lazy collection that requires the data to have been fetched,
but whose data has not yet been requested."""

	def __init__(self, lazy):
		self.lazy = lazy
	def __str__(self):
		return 'Could not return requested data; the sequence of "%s" has not been fetched.' % self.lazy.m_lazy

class StackExchangeLazySequence(list):
	"""Provides a sequence which *can* contain extra data available on an object. It is 'lazy' in the sense that data is only fetched when
required - not on object creation."""

	def __init__(self, m_type, count, site, url, fetch=None, collection=None, **kw):
		self.m_type = m_type
		self.count = count
		self.site = site
		self.url = url
		self.fetch_callback = fetch
		self.kw = kw
		self.collection = collection if collection != None else self._collection(url)

	def _collection(self, c):
		return c.split('/')[-1]

	def __len__(self):
		if self.count != None:
			return self.count
		else:
			raise NeedsAwokenError(self)

	def fetch(self, **direct_kw):
		"""Fetch, from the API, the data this sequence is meant to hold."""
		# If we have any default parameters, include them, but overwrite any
		# passed in here directly.
		kw = dict(self.kw)
		kw.update(direct_kw)

		res = self.site.build(self.url, self.m_type, self.collection, kw)
		if self.fetch_callback != None:
			self.fetch_callback(res)
		return res

class StackExchangeLazyObject(list):
	"""Provides a proxy to fetching a single item from a collection, lazily."""

	def __init__(self, m_type, site, url, fetch=None, collection=None):
		self.m_type = m_type
		self.site = site
		self.url = url
		self.fetch_callback = fetch
		self.collection = collection if collection != None else self._collection(url)

	def fetch(self, **kw):
		"""Fetch, from the API, the data supposed to be held."""
		res = self.site.build(self.url, self.m_type, self.collection, kw)[0]
		if self.fetch_callback != None:
			self.fetch_callback(res)
		return res

	def __getattr__(self, key):
		raise NeedsAwokenError

#### Hack, because I can't be bothered to fix my mistaking JSON's output for an object not a dict
# (Si jeunesse savait, si vieillesse pouvait...)
# Attrib: <NAME>, http://stackoverflow.com/questions/1305532/convert-python-dict-to-object/1305663#1305663
class DictObject:
	def __init__(self, entries):
		self.__dict__.update(entries)

class JSONMangler(object):
	"""This class handles all sorts of random JSON-handling stuff"""

	@staticmethod
	def paginated_to_resultset(site, json, typ, collection, params):
		# N.B.: We ignore the 'collection' parameter for now, given that it is
		# no longer variable in v2.x, having been replaced by a generic field
		# 'items'. To perhaps be removed completely at some later point.
		items = []
		
		# create strongly-typed objects from the JSON items
		for json_item in json['items']:
			json_item['_params_'] = params[-1] # convenient access to the kw hash
			items.append(typ(json_item, site))

		rs = StackExchangeResultset(items, params, json['has_more'])
		if 'total' in json:
			rs.total = json['total']

		return rs

	@staticmethod
	def normal_to_resultset(site, json, typ, collection):
		# the parameter 'collection' may be need in future, and was needed pre-2.0
		return tuple([typ(x, site) for x in json['items']])

	@classmethod
	def json_to_resultset(cls, site, json, typ, collection, params=None):
		if 'has_more' in json:
			# we have a paginated resultset
			return cls.paginated_to_resultset(site, json, typ, collection, params)
		else:
			# this isn't paginated (unlikely but possible - eg badges)
			return cls.normal_to_resultset(site, json, typ, collection)

def format_relative_date(date):
	"""Takes a datetime object and returns the date formatted as a string e.g. "3 minutes ago", like the real site.
	This is based roughly on George Edison's code from StackApps:
	http://stackapps.com/questions/1009/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site/1018#1018"""

	now = datetime.datetime.now()
	diff = (now - date).seconds

	# Anti-repetition! These simplify the code somewhat.
	plural = lambda d: 's' if d != 1 else ''
	frmt   = lambda d: (diff / float(d), plural(diff / float(d)))

	if diff < 60:
		return '%d second%s ago' % frmt(1)
	elif diff < 3600:
		return '%d minute%s ago' % frmt(60)
	elif diff < 86400:
		return '%d hour%s ago' % frmt(3600)
	elif diff < 172800:
		return 'yesterday'
	else:
		return date.strftime('M j / y - H:i')

class Sort(Enumeration):
	Activity = 'activity'
	Views = 'views'
	Creation = 'creation'
	Votes = 'votes'

ASC = 'asc'
DESC = 'desc'
