from datetime import timezone, timedelta, date, time, datetime
from calendar import monthrange
from os.path import commonprefix
import math

from .globalconf import malojaconfig


OFFSET = malojaconfig["TIMEZONE"]
TIMEZONE = timezone(timedelta(hours=OFFSET))
UTC = timezone.utc

FIRST_SCROBBLE = int(datetime.utcnow().replace(tzinfo=UTC).timestamp())

def register_scrobbletime(timestamp):
	global FIRST_SCROBBLE
	if timestamp < FIRST_SCROBBLE:
		FIRST_SCROBBLE = int(timestamp)





# Object that represents a contextual time range relevant for displaying chart information
# there is no smaller unit than days
# also, two distinct objects could represent the same timerange
# (e.g. 2019/03 is not the same as 2019/03/01 - 2019/03/31)


# Generic Time Range
class MTRangeGeneric:

	# despite the above, ranges that refer to the exact same real time range should evaluate as equal
	def __eq__(self,other):
		if not isinstance(other,MTRangeGeneric): return False
		return (self.first_stamp() == other.first_stamp() and self.last_stamp() == other.last_stamp())

	# gives a hashable object that uniquely identifies this time range
	def hashable(self):
		return self.first_stamp(),self.last_stamp()


	def info(self):
		return {**self.__json__(),"uri":self.uri()}

	def __json__(self):
		return {
			"fromstring":self.fromstr(),
			"tostr":self.tostr(),
			"fromstamp":self.first_stamp(),
			"tostamp":self.last_stamp(),
			"description":self.desc()
		}

	def uri(self):
		return "&".join(k + "=" + self.urikeys()[k] for k in self.urikeys())

	def unlimited(self):
		return False

	def timestamps(self):
		return (self.first_stamp(),self.last_stamp())

	# whether we currently live or will ever again live in this range
	def active(self):
		return (self.last_stamp() > datetime.utcnow().timestamp())

	def __contains__(self,timestamp):
		return timestamp >= self.first_stamp() and timestamp <= self.last_stamp()

# Any range that has one defining base unit, whether week, year, etc.
class MTRangeSingular(MTRangeGeneric):
	def fromstr(self):
		return str(self)
	def tostr(self):
		return str(self)

	def urikeys(self):
		return {"in":str(self)}


# a range that is exactly a gregorian calendar unit (year, month or day)
class MTRangeGregorian(MTRangeSingular):
	def __init__(self,*ls):
		# in case we want to call with non-unpacked arguments
		if isinstance(ls[0], (tuple, list)): ls = ls[0]

		self.tup = tuple(ls)
		self.precision = len(ls)

		self.year = ls[0]
		if len(ls)>1: self.month = ls[1]
		if len(ls)>2: self.day = ls[2]
		dt = [1970,1,1]
		dt[:len(ls)] = ls
		self.dateobject = date(dt[0],dt[1],dt[2])

	def __str__(self):
		return "/".join(str(part) for part in self.tup)


	# whether we currently live or will ever again live in this range
# USE GENERIC SUPER METHOD INSTEAD
#	def active(self):
#		tod = datetime.datetime.utcnow().date()
#		if tod.year > self.year: return False
#		if self.precision == 1: return True
#		if tod.year == self.year:
#			if tod.month > self.month: return False
#			if self.precision == 2: return True
#			if tod.month == self.month and tod.day > self.day: return False
#		return True



	def desc(self,prefix=False):
		prefixes = (None,'in ','in ','on ')
		formats = ('%Y','%B','%d')

		timeformat = ' '.join(reversed(formats[0:self.precision]))

		if prefix: return prefixes[self.precision] + self.dateobject.strftime(timeformat)
		else: return self.dateobject.strftime(timeformat)


	def informal_desc(self):
		# TODO: ignore year when same year etc
		now = datetime.now(tz=timezone.utc)
		today = date(now.year,now.month,now.day)
		if self.precision == 3:
			diff = (today - dateobject).days
			if diff == 0: return "Today"
			if diff == 1: return "Yesterday"
			if diff < 7 and diff > 1: return timeobject.strftime("%A")
		#elif len(t) == 2:
		return self.desc()

	# describes only the parts that are different than another range object
	def contextual_desc(self,other):
		# TODO: more elegant maybe?
		if not isinstance(other, MTRangeGregorian): return self.desc()

		relevant = self.desc().split(" ")
		if self.year == other.year:
			relevant.pop()
			if self.precision > 1 and other.precision > 1 and self.month == other.month:
				relevant.pop()
				if self.precision > 2 and other.precision > 2 and self.day == other.day:
					relevant.pop()
		return " ".join(relevant)


	# get objects with one higher precision that start or end this one
	def start(self):
		if self.precision in [1, 2]: return MTRangeGregorian(*self.tup,1)
		return self
	def end(self):
		if self.precision == 1: return MTRangeGregorian(*self.tup,12)
		elif self.precision == 2: return MTRangeGregorian(*self.tup,monthrange(self.year,self.month)[1])
		return self

	# get highest precision objects (day) that start or end this one
	def first_day(self):
		if self.precision == 3: return self
		else: return self.start().first_day()
	def last_day(self):
		if self.precision == 3: return self
		else: return self.end().last_day()

	# get first or last timestamp of this range
	def first_stamp(self):
		day = self.first_day().dateobject
		return int(datetime.combine(day,time(tzinfo=TIMEZONE)).timestamp())
	def last_stamp(self):
		day = self.last_day().dateobject + timedelta(days=1)
		return int(datetime.combine(day,time(tzinfo=TIMEZONE)).timestamp() - 1)

	# next range of equal length (not exactly same amount of days, but same precision level)
	def next(self,step=1):
		if abs(step) == math.inf: return None
		if self.precision == 1:
			return MTRangeGregorian(self.year + step)
		elif self.precision == 2:
			dt = [self.year,self.month]
			dt[1] += step
			while dt[1] > 12:
				dt[1] -= 12
				dt[0] += 1
			while dt[1] < 1:
				dt[1] += 12
				dt[0] -= 1
			return MTRangeGregorian(*dt)
		elif self.precision == 3:
			newdate = self.dateobject + timedelta(days=step)
			return MTRangeGregorian(newdate.year,newdate.month,newdate.day)
	def prev(self,step=1):
		return self.next(step*(-1))



# a range that is exactly one christian week (starting on sunday)
class MTRangeWeek(MTRangeSingular):
	def __init__(self,year=None,week=None):

		# do this so we can construct the week with overflow (eg 2020/-3)
		thisisoyear_firstday = date.fromchrcalendar(year,1,1)
		self.firstday = thisisoyear_firstday + timedelta(days=7*(week-1))
		self.firstday = date(self.firstday.year,self.firstday.month,self.firstday.day)
		# for compatibility with pre python3.8 (https://bugs.python.org/issue32417)


		self.lastday = self.firstday + timedelta(days=6)

		# now get the actual year and week number (in case of overflow)
		self.year,self.week,_ = self.firstday.chrcalendar()



	def __str__(self):
		return f"{self.year}/W{self.week}"



	def desc(self,prefix=False):
		if prefix:
			return f"in Week {self.week} {self.year}"
		else:
			return f"Week {self.week} {self.year}"

	def informal_desc(self):
		now = datetime.now(tz=timezone.utc)
		if now.year == self.year: return f"Week {self.week}"
		return self.desc()

	def contextual_desc(self,other):
		if isinstance(other, MTRangeWeek) and other.year == self.year: return f"Week {self.week}"
		return self.desc()

	def start(self):
		return self.first_day()
	def end(self):
		return self.last_day()

	def first_day(self):
		return MTRangeGregorian(self.firstday.year,self.firstday.month,self.firstday.day)
	def last_day(self):
		return MTRangeGregorian(self.lastday.year,self.lastday.month,self.lastday.day)

	def first_stamp(self):
		day = self.firstday
		return int(datetime.combine(day,time(tzinfo=TIMEZONE)).timestamp())
	def last_stamp(self):
		day = self.lastday + timedelta(days=1)
		return int(datetime.combine(day,time(tzinfo=TIMEZONE)).timestamp() - 1)

	def next(self,step=1):
		if abs(step) == math.inf: return None
		return MTRangeWeek(self.year,self.week + step)

# a range that is defined by separate start and end
class MTRangeComposite(MTRangeGeneric):

	def __init__(self,since=None,to=None):
		since,to = time_pad(since,to)
		self.since = since
		self.to = to
		if isinstance(self.since,MTRangeComposite): self.since = self.since.start()
		if isinstance(self.to,MTRangeComposite): self.to = self.to.end()

	def __str__(self):
		return f"{self.since} - {self.to}"
	def fromstr(self):
		return str(self.since)
	def tostr(self):
		return str(self.to)

	# whether we currently live or will ever again live in this range
	def active(self):
		if self.to is None: return True
		return self.to.active()

	def unlimited(self):
		return (self.since is None and self.to is None)

	def urikeys(self):
		keys = {}
		if self.since is not None: keys["since"] = str(self.since)
		if self.to is not None: keys["to"] = str(self.to)
		return keys

	def desc(self,prefix=False):
		if self.since is not None and self.to is not None:
			if prefix:
				return f"from {self.since.contextual_desc(self.to)} to {self.to.desc()}"
			else:
				return f"{self.since.contextual_desc(self.to)} to {self.to.desc()}"
		if self.since is not None and self.to is None:
			return f"since {self.since.desc()}"
		if self.since is None and self.to is not None:
			return f"until {self.to.desc()}"
		if self.since is None and self.to is None:
			return ""

	def informal_desc(self):
		# dis gonna be hard
		return "Not implemented"

	def start(self):
		return self.since

	def end(self):
		return self.to

	def first_day(self):
		return self.since.first_day()
	def last_day(self):
		return self.to.last_day()

	def first_stamp(self):
		if self.since is None: return FIRST_SCROBBLE
		else: return self.since.first_stamp()
	def last_stamp(self):
		if self.to is None: return int(datetime.utcnow().replace(tzinfo=timezone.utc).timestamp())
		else: return self.to.last_stamp()

	def next(self,step=1):
		if abs(step) == math.inf: return None
		if self.since is None or self.to is None: return None
		# hop from the start element by one until we reach the end element
		diff = 1
		nxt = self.since
		while (nxt != self.to):
			diff += 1
			nxt = nxt.next(step=1)

		newstart = self.since.next(step=diff*step)
		newend = self.to.next(step=diff*step)

		return MTRangeComposite(newstart,newend)






def today():
	tod = datetime.now(tz=TIMEZONE)
	return MTRangeGregorian(tod.year,tod.month,tod.day)
def thisweek():
	tod = datetime.now(tz=TIMEZONE)
	tod = date(tod.year,tod.month,tod.day)
	y,w,_ = tod.chrcalendar()
	return MTRangeWeek(y,w)
def thismonth():
	tod = datetime.now(tz=TIMEZONE)
	return MTRangeGregorian(tod.year,tod.month)
def thisyear():
	tod = datetime.now(tz=TIMEZONE)
	return MTRangeGregorian(tod.year)
def alltime():
	return MTRangeComposite(None,None)





def range_desc(r,**kwargs):
	if r is None: return ""
	return r.desc(**kwargs)

def time_str(t):
	obj = time_fix(t)
	return obj.desc()


currenttime_string_representations = (
	(today,["today","day"]),
	(thisweek,["week","thisweek"]),
	(thismonth,["month","thismonth"]),
	(thisyear,["year","thisyear"]),
	(lambda:None,["alltime"])
)
month_string_representations = (
	["january","jan"],
	["february","feb"],
	["march","mar"],
	["april","apr"],
	["may"],
	["june","jun"],
	["july","jul"],
	["august","aug"],
	["september","sep"],
	["october","oct"],
	["november","nov"],
	["december","dec"],
)
weekday_string_representations = (
	["sunday","sun"],
	["monday","mon"],
	["tuesday","tue"],
	["wednesday","wed"],
	["thursday","thu"],
	["friday","fri"],
	["saturday","sat"]
)

def get_last_instance(category,current,target,amount):
	offset = (target-current) % -(amount)
	return category().next(offset)

str_to_time_range = {
	**{s:callable for callable,strlist in currenttime_string_representations for s in strlist},
	**{s:(lambda i=index:get_last_instance(thismonth,datetime.utcnow().month,i,12)) for index,strlist in enumerate(month_string_representations,1) for s in strlist},
	**{s:(lambda i=index:get_last_instance(today,datetime.utcnow().isoweekday()+1%7,i,7)) for index,strlist in enumerate(weekday_string_representations,1) for s in strlist}
}


# converts strings and stuff to objects
def time_fix(t):
	if t is None or isinstance(t,MTRangeGeneric): return t

	if isinstance(t, str):
		t = t.lower()

		if t in str_to_time_range:
			return str_to_time_range[t]()

	if isinstance(t,str): t = t.split("/")
	#if isinstance(t,tuple): t = list(t)
	try:
		t = [int(p) for p in t]
		return MTRangeGregorian(t[:3])
	except:
		pass

	if isinstance(t[1],str) and t[1].startswith("w"):
		try:
			year = int(t[0])
			weeknum = int(t[1][1:])
			return MTRangeWeek(year=year,week=weeknum)
		except:
			raise



def get_range_object(since=None,to=None,within=None):

	since,to,within = time_fix(since),time_fix(to),time_fix(within)

	# check if we can simplify
	if since is not None and to is not None and since == to: within = since
	# TODO

	if within is not None:
		return within
	else:
		return MTRangeComposite(since,to)




# makes times the same precision level
def time_pad(f,t,full=False):

	if f is None or t is None: return f,t

	# week handling
	if isinstance(f,MTRangeWeek) and isinstance(t,MTRangeWeek):
		if full: return f.start(),t.end()
		else: return f,t
	if not isinstance(f,MTRangeWeek) and isinstance(t,MTRangeWeek):
		t = t.end()
	if isinstance(f,MTRangeWeek) and not isinstance(t,MTRangeWeek):
		f = f.start()

	while (f.precision < t.precision) or (full and f.precision < 3):
		f = f.start()
	while (f.precision > t.precision) or (full and t.precision < 3):
		t = t.end()

	return f,t








### TIMESTAMPS

def timestamp_desc(t,short=False):

	timeobj = datetime.fromtimestamp(t,tz=TIMEZONE)

	if not short: return timeobj.strftime(malojaconfig["TIME_FORMAT"])

	difference = int(datetime.now().timestamp() - t)

	thresholds = (
		(10,"just now"),
		(2*60,f"{difference} seconds ago"),
		(2*60*60,f"{difference/60:.0f} minutes ago"),
		(2*24*60*60,f"{difference/(60*60):.0f} hours ago"),
		(5*24*60*60,f"{timeobj.strftime('%A')}"),
		(31*24*60*60,f"{difference/(60*60*24):.0f} days ago"),
		(12*31*24*60*60,f"{timeobj.strftime('%B')}"),
		(math.inf,f"{timeobj.strftime('%Y')}")
	)

	for t,s in thresholds:
		if difference < t: return s










def time_stamps(since=None,to=None,within=None,range=None):

	if range is None: range = get_range_object(since=since,to=to,within=within)
	return range.first_stamp(),range.last_stamp()



def delimit_desc_p(d):
	return delimit_desc(**d)

def delimit_desc(step="month",stepn=1,trail=1):
	txt = ""
	if stepn != 1: txt += str(stepn) + "-"
	txt += {"year":"Yearly","month":"Monthly","week":"Weekly","day":"Daily"}[step.lower()]
	if trail is math.inf: txt += " Cumulative"
	elif trail != 1: txt += " Trailing" #we don't need all the info in the title

	return txt




def day_from_timestamp(stamp):
	dt = datetime.fromtimestamp(stamp,tz=TIMEZONE)
	return MTRangeGregorian(dt.year,dt.month,dt.day)
def month_from_timestamp(stamp):
	dt = datetime.fromtimestamp(stamp,tz=TIMEZONE)
	return MTRangeGregorian(dt.year,dt.month)
def year_from_timestamp(stamp):
	dt = datetime.fromtimestamp(stamp,tz=TIMEZONE)
	return MTRangeGregorian(dt.year)
def week_from_timestamp(stamp):
	dt = datetime.fromtimestamp(stamp,tz=TIMEZONE)
	d = date(dt.year,dt.month,dt.day)
	y,w,_ = d.chrcalendar()
	return MTRangeWeek(y,w)

def from_timestamp(stamp,unit):
	if unit == "day": return day_from_timestamp(stamp)
	if unit == "week": return week_from_timestamp(stamp)
	if unit == "month": return month_from_timestamp(stamp)
	if unit == "year": return year_from_timestamp(stamp)


# since, to and within can accept old notations or objects. timerange can only be a new object.
def ranges(since=None,to=None,within=None,timerange=None,step="month",stepn=1,trail=1,max_=None):

	(firstincluded,lastincluded) = time_stamps(since=since,to=to,within=within,range=timerange)

	d_start = from_timestamp(firstincluded,step)
	d_start = d_start.next(stepn-1) #last part of first included range
	i = 0
	current_end = d_start
	current_start = current_end.next((stepn*trail-1)*-1)
	#ranges = []
	while current_end.first_stamp() < lastincluded and (max_ is None or i < max_):


		if current_start == current_end:
			yield current_start
			#ranges.append(current_start)
		else:
			yield MTRangeComposite(current_start,current_end)
			#ranges.append(MTRangeComposite(current_start,current_end))
		current_end = current_end.next(stepn)
		current_start = current_end.next((stepn*trail-1)*-1)

		i += 1

	#return ranges
