# When - display a when calendar
# Copyright (C) 2023 Duane Robertson

# This program is only based on the data format used by
# when, and does not include any of the original code.

# https://www.lightandmatter.com/when/when.html

# when.gd

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.


extends Node2D


const DAY = 24 * 60 * 60
const FILE_NAME = 'res://calendar'
const FUTURE = 365.0 # 365.0 * 10.0

var output:RichTextLabel
var reg_all_digit:RegEx
var reg_birth_year:RegEx
var reg_exp:RegEx
var reg_simple_date:RegEx
var months := ['jan', 'feb', 'mar', 'apr', 'may', 'jun', \
		'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
var weekdays := ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
var operator := {
	'%' : 7,
	'-' : 6,
	'<' : 5, '>' : 5, '<=' : 5, '>=' : 5,
	'=' : 4, '!=' : 4,
	'!' : 3,
	'&' : 2,
	'|' : 1,
	}


# Called when the node enters the scene tree for the first time.
func _ready():
	var sdr := '(\\d{4}\\*?|\\*) ([a-zA-Z]{3}|\\*) (\\d{1,2}|\\*)'
	reg_simple_date = RegEx.new()
	reg_simple_date.compile(sdr)

	reg_birth_year = RegEx.new()
	reg_birth_year.compile('(\\d{4})\\*')

	reg_exp = RegEx.new()
	var ops = '%|\\-|<|>|<=|>=|=|!=|!|&|\\|'
	reg_exp.compile('\\s*([a-z0-9]+|' + ops + ')\\s*')

	reg_all_digit = RegEx.new()
	reg_all_digit.compile('^[0-9]+$')

	assert(reg_simple_date.search('2022 apr 02'))

	output = RichTextLabel.new()
	# output.bbcode_enabled = true
	output.size = Vector2(640, 1280)
	output.position = Vector2(40, 0)
	output.set('theme_override_fonts/normal_font', preload('res://art/Roboto-Regular.ttf'))
	output.set('theme_override_font_sizes/normal_font_size', 40)
	# output.add_theme_font_override('Normal', preload('res://art/Roboto-Bold.ttf'))
	# print(output.get_theme_font('default'))
	# print(output.get_theme_font_size('default'))
	add_child(output)
	# output.add_theme_font_size_override('default', 150)
	# print(output.get_theme_font_size('default'))

	var cal:String = FileAccess.get_file_as_string(FILE_NAME)

	var epoch = Time.get_unix_time_from_system()
	var outd:Array = calendar(cal, epoch)

	print_calendar(outd, epoch)

	# print(output.text)


# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
	pass


func bday(num:int) -> String:
	var lnum = num % 10
	var snum = int(num / 10) % 10

	if snum == 1:
		return '%dth' % num
	elif lnum == 1:
		return '%dst' % num
	elif lnum == 2:
		return '%dnd' % num
	elif lnum == 3:
		return '%drd' % num
	else:
		return '%dth' % num


func cal_match_dmy(md:Dictionary, date:Dictionary):
	if not ('*' in md.year or int(md.year) == date.year):
		return
	if not ('*' in md.month or md.month.to_lower() == months[date.month-1]):
		return
	if not ('*' in md.day or int(md.day) == date.day):
		return

	if '*' in md.year and len(md.year) > 1:
		var bym = reg_birth_year.search(md.year)
		if bym:
			md.birth_year = bym.get_string(1)

	return md


func cal_match_rpn(mt:Dictionary, date):
	# Evaluate the rpn expression.

	var stack := []
	var q = mt.rpn.duplicate()

	while not q.is_empty():
		var t = q.pop_front()
		if operator.has(t):
			var a
			var o1
			var o2

			if t == '!':  # only unary operator
				o1 = stack.pop_front()
			else:
				o2 = stack.pop_front()
				o1 = stack.pop_front()

			if t == '!':
				a = not o1
			elif t == '%':
				a = int(o1) % int(o2)
			elif t == '-':
				a = int(o1) - int(o2)
			elif t == '=':
				a = (o1 == o2)
			elif t == '!=':
				a = (o1 != o2)
			elif t == '&':
				a = (o1 and o2)
			elif t == '|':
				a = (o1 or o2)
			elif t == '<':
				a = (o1 < o2)
			elif t == '>':
				a = (o1 > o2)
			elif t == '<=':
				a = (o1 <= o2)
			elif t == '>=':
				a = (o1 >= o2)
			else:
				assert(false)

			if a != null:
				stack.push_front(a)
		else:
			stack.push_front(replace_variables(t, date))

	assert(len(stack) == 1)
	if stack.pop_front():
		return {}


func calendar(data_in:String, epoch) -> Array:
	var out := ''
	var outd := []

	var data := data_convert(data_in)

	for ep in range(epoch - DAY * 2, epoch + DAY * int(FUTURE), DAY):
		var date = Time.get_datetime_dict_from_unix_time(ep)
		get_julian(date)

		for dt in data:
			var show
			if dt.type == 'simple':
				show = cal_match_dmy(dt, date)
			elif dt.type == 'exp':
				show = cal_match_rpn(dt, date)

			if show != null:
				if show.has('birth_year') and '\\a' in dt.note:
					var bdays = bday(date.get('year') - int(show.get('birth_year')))
					var n = dt.note.replace('\\a', '%s' % bdays)
					outd.append([date, n])
				# if 'birth_year' in show and r'\a' in dt.get('note'):
				#     bdays = bday(date.get('year') - int(show.get('birth_year')))
				#     n = dt.get('note').replace(r'\a', '%s' % bdays)
				#     outd.append([date, n])
				else:
					outd.append([date, dt.note])

	return outd


func ctag(s:String, c:Color) -> String:
	# return '[color=#' + c.to_html().right(2) + ']' + s + '[/color]'
	return '[color=#' + c.to_html() + ']' + s + '[/color]'


func data_convert(data_in:String) -> Array:
	var data:Array
	var data1:Array = data_in.split('\n')

	for i in len(data1):
		var s = data1[i].strip_edges()
		if s.substr(0, 1) == '#':
			continue

		var d = s.split(',')
		if len(d) != 2:
			continue
		for j in len(d):
			d[j] = d[j].strip_edges()

		var m = reg_simple_date.search(d[0])
		if m:
			var md := {
				type = 'simple',
				year = m.get_string(1),
				month = m.get_string(2),
				day = m.get_string(3),
				note = d[1],
			}
			data.append(md)

		else:
			var md := {
				type = 'exp',
				rpn = make_rpn(d[0]),
				note = d[1],
				}
			data.append(md)

	return data


func dlog(s:String):
	output.append_text(s + '\n')


func get_days_in_month(date) -> int:
	var ds:int = 31
	if date.month in [4,6,9,11]:
		ds = 30
	elif date.month == 2:
		ds = 28
		if date.year % 4 == 0:
			ds = 29
	return ds


func get_julian(date:Dictionary):
	var p1:int = (date.month - 14) / 12
	var p2:int = date.year + 4800 + p1
	var julian = (1461 * p2) / 4 \
			+ (367 * (date.month - 2 - 12 * p1)) / 12 \
			- (3 * ((p2 + 100) / 100)) / 4 \
			+ date.day - 32075 - 2400000
	##############################################
	# When uses modified julian, so this is wrong.
	##############################################
	var jn:float = julian + (date.hour - 12) / 24.0 \
			+ date.minute / 1440.0 + date.second / 86400.0
	##############################################

	date.jn = jn
	date.julian = julian


func get_week(date:Dictionary, reverse:=false) -> int:
	var a:int = int(ceil(date.day / 7.0))

	if reverse:
		var ds := get_days_in_month(date)
		a = int(ceil((ds - date.day + 1) / 7.0))

	return a


func make_rpn(s:String) -> Array:
	# Change the infix expression to reverse
	#  polish using the shunting yard algorithm.

	var toks_in = reg_exp.search_all(s)
	var toks = [ ]
	for tok in toks_in:
		toks.append(tok.get_string(1))
		assert(toks[-1] is String)

	var outq := []
	var opq := []
	while not toks.is_empty():
		var t:String = toks.pop_front()

		if operator.has(t):
			while not opq.is_empty() and opq[-1] != '(' \
					and operator[opq[-1]] >= operator[t]:
				outq.push_back(opq.pop_back())
			opq.push_back(t)
		elif t == '(':
			opq.push_back(t)
		elif t == ')':
			while not opq.is_empty() and opq[-1] != '(':
				assert(not opq.is_empty())
				outq.push_back(opq.pop_back())
			assert(opq[-1] == '(')
			opq.pop_back()
		else:
			if reg_all_digit.search(t):
				outq.push_back(int(t))
			elif t is String and t.to_lower() in months:
				outq.push_back(months.find(t.to_lower())+1)
			elif t is String and t.to_lower() in weekdays:
				outq.push_back(weekdays.find(t.to_lower()))
			else:
				outq.push_back(t)

	while not opq.is_empty():
		assert(opq[-1] != '(')
		outq.push_back(opq.pop_back())

	return outq


func print_calendar(outd:Array, epoch:int):
	var date = Time.get_datetime_dict_from_system()
	get_julian(date)

	var date_out = [
		scase(weekdays[date.weekday]),
		int(date.year),
		scase(months[date.month-1]),
		int(date.day)
		]
	dlog('It is %s, %4d %s %2d\n' % date_out)

	var lastd
	var displayed = {}
	var c:Color = Color(0.9, 0.9, 0.9)

	for it in outd:
		if displayed.has(it[1]):
			continue

		var dist:int = abs(date.julian - it[0].julian)
		var d:float = float(0.5 + (FUTURE - dist) / FUTURE * 0.5)

		if lastd != it[0].julian:
			if lastd != null:
				dlog('')

			date_out = [
				int(it[0].year),
				scase(months[it[0].month-1]),
				int(it[0].day)
				]

			lastd = it[0].julian

			if dist < 2:
				if it[0].julian < date.julian:
					d = d - 0.3
					c = Color(d, d, d)
					date_out.push_front('Yesterday')
				elif it[0].julian > date.julian:
					c = Color(d * 0.6, d, d * 0.6)
					date_out.push_front('Tomorrow')
				else:
					c = Color(d * 0.6, d, d * 0.6)
					date_out.push_front('Today')

				dlog(ctag('%s, %4d %s %2d', c) % date_out)
			else:
				if it[0].julian < date.julian:
					d = d - 0.3
				c = Color(d, d, d)

				date_out.push_front(scase(weekdays[it[0].weekday]))
				dlog(ctag('%s, %4d %s %2d', c) % date_out)

		if it[0].julian >= date.julian:
			displayed[it[1]] = true
		dlog((ctag('\t%s', c)) % [it[1]])


#func print_rpn(q:Array) -> void:
#	var s = ''
#	for t in q:
#		s += ',' + t
#	dlog(s)


func replace_variables(i_t, date:Dictionary):
	var t = i_t

	if not t is String:
		return t

	if t == 'j':
		t = date.julian
	elif t == 'm':
		t = date.month
	elif t == 'd':
		t = date.day
	elif t == 'w':
		t = date.weekday
	elif t == 'y':
		t = date.year
	elif t == 'a':
		t = get_week(date, false)
	elif t == 'b':
		t = get_week(date, true)
	elif t == 'e':
		########################
		# Add Easter... someday.
		########################
		t = 999999
		########################

	return t


func scase(s:String) -> String:
	return s[0].to_upper() + s.substr(1)