#!/usr/bin/env python

# Copyright 2008, Philip M. White <pmw@qnan.org>
#
# 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 <http://www.gnu.org/licenses/>.
#
# Last update: August 25, 2008

import datetime
import getopt
import math
import sys
import time
import xml.dom.minidom

# This switch enables the functionality of debug_write().
DEBUG = 0

class EventError(Exception):
	pass

def debug_write(str):
	if DEBUG:
		sys.stdout.write(str)

def read_types(filename):
	reserved_types = set(['free'])
	types = {}
	dom = xml.dom.minidom.parse(filename)
	if dom.documentElement.nodeName != "types":
		sys.stderr.write("The types input file has a wrong document element.  Found: \""+dom.documentElement.nodeName +"\", but expected \"types\".\n")
	outernode = dom.firstChild.firstChild
	while outernode is not None:
		if outernode.nodeType is outernode.ELEMENT_NODE and outernode.nodeName == "type":
			typenode = outernode.firstChild
			typename = None
			while typenode is not None:
				if typenode.nodeType is typenode.ELEMENT_NODE:
					n = typenode.nodeName
					if typenode.firstChild is None:
						v = ""
					else:
						v = typenode.firstChild.nodeValue
					if n == "name":
						if v == "":
							sys.stderr.write('Warning: you cannot define a type with an empty name.\n')
							break
						if v in reserved_types:
							sys.stderr.write('Warning: you cannot define a type named "'+v+'".\n')
							break
						typename = v
						types[typename] = {}
					elif n == "fg" or n == "bg":
						if typename not in types:
							sys.stderr.write('Warning: you cannot assign a color to a type that has an empty or undefined name.\n')
							break
						types[typename][n] = v
				typenode = typenode.nextSibling
		outernode = outernode.nextSibling

	return types

def read_schedule(filename):
	reserved_types = set(['free', 'private'])
	events = []	# an array of 'event' hashes
	config = {}
	dom = xml.dom.minidom.parse(filename)
	if dom.documentElement.nodeName != "schedule":
		sys.stderr.write("The schedule input file has a wrong document element.  Found: \""+dom.documentElement.nodeName +"\", but expected \"schedule\".\n")
	outernode = dom.firstChild.firstChild
	while outernode is not None:
		if outernode.nodeType is outernode.ELEMENT_NODE and outernode.nodeName == "event":
			event = {}
			eventnode = outernode.firstChild
			while eventnode is not None:
				if eventnode.nodeType is eventnode.ELEMENT_NODE:
					n = eventnode.nodeName
					v = eventnode.firstChild.nodeValue
					if n == "days":
						event[n] = []
						for i in range(0, len(v)):
							event[n].append(int(v[i]))
						event[n] = set(event[n])
					elif n == "start" or n == "finish":
						event[n] = ts_str2frac(v)
					elif n == "asof" or n == "until":
						event[n] = datetime.date(*time.strptime(v, "%Y-%m-%d")[0:3])
					else:
						event[n] = v
				eventnode = eventnode.nextSibling
			# validation
			if 'type' not in event:
				raise EventError('every event must have a type')
			if 'start' not in event:
				event['start'] = 0
			if 'finish' in event and event['start'] > event['finish']:
				raise EventError("an event must start before it finishes")
			if 'prio' in event and event['prio'] != "fixed" and event['prio'] != "flexible":
				raise EventError("the priority of an event, if defined, must be 'fixed' or 'flexible'")
			elif 'prio' not in event:
				event['prio'] = "flexible"
			if 'type' in event and event['type'] in reserved_types:
				raise EventError('the event type name "'+event['type']+'" is reserved, so you must not use it')
			events.append(event)
		elif outernode.nodeType is outernode.ELEMENT_NODE and outernode.nodeName == "config":
			confignode = outernode.firstChild
			while confignode is not None:
				if confignode.nodeType is confignode.ELEMENT_NODE:
					n = confignode.nodeName
					v = confignode.firstChild.nodeValue
					if n == "time-granularity-pub" or n == "time-granularity-pri":
						config[n] = float(v)
					elif n == "time-start" or n == "time-end":
						config[n] = ts_str2frac(v)
					else:
						config[n] = v
				confignode = confignode.nextSibling
		outernode = outernode.nextSibling
	return events, config

# Take a decimal fraction and return a datetime.time object of the fractional representation of time.
def ts_frac2time(ts):
	hour = int(ts)
	minute = ts - hour
	if minute != 0:
		minute = round(60 * minute)
	minute = int(minute)
	if minute < 0 or minute > 59:
		sys.stderr.write("ERROR in ts_frac2time(): when ts is "+str(ts)+", hour is "+str(hour)+" and minute is "+str(minute)+"\n")
	return datetime.time(hour, minute)

def ts_cmp(x, y):
	if x < y:
		return -1
	if x > y:
		return 1
	return 0

# Take a decimal fraction and return a string representation thereof.
def ts_frac2str(ts):
	thetime = ts_frac2time(ts)
	hour = 0
	minute = 0
	if thetime.hour < 10:
		hour = "0"+str(thetime.hour)
	else:
		hour = str(thetime.hour)
	if thetime.minute < 10:
		minute = "0"+str(thetime.minute)
	else:
		minute = str(thetime.minute)
	return hour+":"+minute

# Take a string in the format 'HH:MM' and return a decimal fraction representation thereof.
def ts_str2frac(ts):
	if len(ts) != 5 or ts[2] != ':':
		raise Exception('A time must be formatted as "HH:MM".')
	hour = int(ts[:2])
	min = int(ts[3:])
	return hour + float(min)/60

# Returns a hash of 'event type -> decimal' showing how many hours each event type occupies.
def durations_compute():
	durations = {}	# key is the category, value is the duration in hours
	today = datetime.date.today()
	dur_sum = 0
	for e in events:
		if 'until' in e and e['until'] < today:
			continue	# the event is finished
		dur = 0
		if 'finish' in e:
			dur = e['finish'] - e['start']
		else:
			dur = 24 - e['start']
		dur *= len(e['days'])
		if 'type' not in e:
			raise Exception('Every event must have a type.')
		if e['type'] not in durations:
			durations[e['type']] = 0
		durations[e['type']] += dur
		dur_sum += dur
	durations['free'] = 24*7 - dur_sum
	return durations

# This function is called by page_build_full().
# This event matrix is used to more efficiently generate the output later.
# Its rows are the timeslots (configured by time-start, time-end, and time-granularity),
# and its columns are the days of the week.  The first day of the week is Monday.
# Every event (from the 'events' structure) creates a 3-tuple in one cell of the
# event matrix.  This 3-tuple contains:
#    element 0: the index of the event from the 'events' structure
#    element 1: the number of rows that this event occupies (used for the "rowspan" attr in HTML)
#    element 2: the index of the footnote from the 'footnotes' structure, or None if no footnote
# Thus, every cell of the event matrix that /begins/ a new event has this 3-tuple.
# All other cells are None.
def event_build_matrix():
	matrix = []
	footnotes = []
	# set the dimensions of 'matrix'.
	for r in range(0, int(round((config['time-end'] - config['time-start']) / config['time-granularity']))):
		matrix.append([])
		for c in range(0, 7):
			matrix[r].append(None)
	# start placing data into the matrix.
	for ei in range(0, len(events)):
		e = events[ei]
		debug_write('<!-- Begin examining event idx '+str(ei)+', "'+e['name']+'" on days '+str(e['days'])+' and beginning at '+ts_frac2str(e['start'])+' -->\n')
		cell_start = (e['start'] - config['time-start']) / config['time-granularity']
		debug_write('<!-- unrounded, cell_start is '+str(cell_start)+' -->\n')
		if cell_start - int(cell_start) > 0.5:	# 0.5 is a magic number.  I don't remember anymore what it does.
			debug_write('<!-- incrementing cell_start -->\n')
			cell_start += 1
		cell_start = int(cell_start)
		slot_offset = None
		if cell_start < 0:
			cell_start = 0
		time = cell_start * config['time-granularity'] + config['time-start']	# time of the first slot
		debug_write('<!-- time = '+ts_frac2str(time)+' ('+str(time)+') -->\n')
		if 'finish' in e:
			num_rows = int(round((e['finish'] - max(config['time-start'], time)) / config['time-granularity']))
			f = time + num_rows*config['time-granularity'] - config['time-granularity']/2	# this is the middle of the last timeslot that's covered up by this event
			# does the event finish earlier than halfway through the last timeslot?  If so, this means that a new event can begin in that timeslot.  We must finish sooner.
			if e['finish'] < f+0.001:
				num_rows -= 1
				debug_write('<!-- subtracted 1 from rows, resulting in '+str(num_rows)+' -->\n')
		else:
			num_rows = int(round((config['time-end'] - config['time-start']) / config['time-granularity']))
		debug_write('<!-- ended up with num_rows being '+str(num_rows)+' -->\n')
		if num_rows == 0:
			sys.stderr.write('The event "'+e['name']+'" on days '+str(e['days'])+' is not displayed because ')
			if e['finish'] < config['time-start']+0.001 or e['start']+0.001 > config['time-end']:
				sys.stderr.write('it is outside of the schedule display boundaries')
			else:
				sys.stderr.write('its duration ('+str(round(e['finish']-e['start'], 3))+') is too short')
			sys.stderr.write('.\n')
			continue
		if len(matrix) <= cell_start:
			sys.stderr.write('The event "'+e['name']+'", which starts at '+ts_frac2str(e['start'])+', starts later than the schedule displays.\n')
			continue
		footnote_idx = None
		if not footnotes_ignore:
			footnote_str = ""	# not None because we want to be able to append
			if 'asof' in e and e['asof'] > datetime.date.today():
				footnote_str += "starting "+str(e['asof'])
				if 'until' in e:
					footnote_str += " and "
			if 'until' in e:
				footnote_str += "until "+str(e['until'])
			if footnote_str != "":
				try:
					footnotes.index(footnote_str)
				except:
					footnotes.append(footnote_str)
				footnote_idx = footnotes.index(footnote_str)+1
		# Finally, here we add the event to the matrix for each day of the event.
		for day in e['days']:
			if matrix[cell_start][day-1] is not None:
				sys.stderr.write('Overwriting matrix['+str(cell_start)+']['+str(day-1)+']; event "'+e['name']+'" is replacing "'+events[matrix[cell_start][day-1][0]]['name']+'"\n')
			matrix[cell_start][day-1] = [ei, num_rows, footnote_idx]
	return matrix, footnotes

# This is the entry function for generating and outputting the 'fancy' (primary mode) schedule.
def page_build_full():
	footnotes = []
	print('<p>Entries with a thicker and darker border cannot be moved or canceled without a strong reason.')
	if privacy_enabled:
		sys.stdout.write("This webpage protects the owner's privacy by not displaying event specifics")
		if config['time-granularity-pri'] < config['time-granularity-pub']:
			sys.stdout.write(' and by reducing the precision of event start and finish times')
		sys.stdout.write('.  If you are authorized, <a href="private">view the detailed and precise schedule</a>.')
	else:
		print('This is the private edition of the schedule, which means it displays event specifics and precise times.  Please do not make this webpage publicly available.')
	print('''</p>
<table id="schedule">
<tr>
	<th></th>
	<th class="day">Monday</th>
	<th class="day">Tuesday</th>
	<th class="day">Wednesday</th>
	<th class="day">Thursday</th>
	<th class="day">Friday</th>
	<th class="day">Saturday</th>
	<th class="day">Sunday</th>
</tr>
''')
	event_matrix, footnotes = event_build_matrix()

	# The 'timeout' structure is necessary by how HTML is designed.  Once we make a table cell
	# with a "rowspan" attribute, we must skip a certain number of table-data (td) tags in
	# subsequent table rows.  Thus, when this code generates a new table cell, it places the
	# quantity of subsequent rows to skip into 'timeout' for its day.  Each time a new row is
	# built while 'timeout' is non-zero, no new table-data is generated and instead the value
	# of 'timeout' is decreased.
	timeout = [0, 0, 0, 0, 0, 0, 0]
	for r in range(0, len(event_matrix)):
		sys.stdout.write('<tr><td>'+ts_frac2str(config['time-start'] + config['time-granularity']*r)+'</td>\n')
		for d in range(0, len(event_matrix[r])):
			debug_write('<!-- event_matrix['+str(r)+']['+str(d)+'] is '+str(event_matrix[r][d])+'.  The day\'s timeout is '+str(timeout[d])+' -->\n')
			tuple = event_matrix[r][d]
			if timeout[d] > 0:
				sys.stdout.write('\t<!-- slot occupied -->\n')
				timeout[d] -= 1
			elif tuple is None:
				sys.stdout.write('\t<td class="empty"></td>\n')
			else:
				if privacy_enabled:
					sys.stdout.write('\t<td rowspan="'+str(tuple[1])+'" class="cat-private prio-'+events[tuple[0]]['prio']+'">')
				else:
					sys.stdout.write('\t<td rowspan="'+str(tuple[1])+'" class="cat-'+events[tuple[0]]['type']+' prio-'+events[tuple[0]]['prio']+'">'+events[tuple[0]]['name'])
				if not privacy_enabled and events[tuple[0]]['start']+0.00001 > config['time-start'] and 'finish' in events[tuple[0]]:
					sys.stdout.write(' ('+ts_frac2str(events[tuple[0]]['start'])+'&ndash;'+ts_frac2str(events[tuple[0]]['finish'])+')')
				if tuple[2] is not None: # we have a footnote
					sys.stdout.write('<sup>'+str(tuple[2])+'</sup>')
				sys.stdout.write('</td>\n')
				timeout[d] = tuple[1]-1
		sys.stdout.write('</tr>\n')

	print('</table>')
	if len(footnotes) > 0:
		print('<div id="sch-foot">')
		print('<h2>Footnotes</h2>')
		print('<ol>')
		for f in footnotes:
			print('\t<li>'+f+'</li>')
		print('</ol>')
		print('</div>')
		print('<div id="sch-cats" style="width: 50%">')
	else:
		print('<div id="sch-cats" style="width: 100%">')
	if not privacy_enabled and len(types) > 0:
		durations = durations_compute()
		print('<h2>Durations</h2>')
		print('<ul id="sch-cats-list">')
		total_hrs = 24*7
		for type in durations.keys():
			if type == "private":	# reserved type
				continue
			sys.stdout.write('\t<li><span class="cat-'+type+'">'+type+'</span>: ')
			sys.stdout.write(str(round(durations[type], 1))+' hours per week ('+str(int(round(durations[type]*100/total_hrs, 0)))+'% of the week)')
			sys.stdout.write('</li>')
		print('</ul>')
	print('</div>')

# The mobile page is used for cell phones and similar devices that croak when presented
# with the full version.  My cell phone has a very simple web browser that cannot handle
# horizontal scrolling, so each table column of the full version is wide enough for only
# one or two characters per line.  This edition of the schedule avoids this problem.
def page_build_mobile():
	dayshort = ['m', 't', 'w', 'r', 'f', 'sa', 'su']
	dayname = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
	print('<p>Bold entries are fixed: they cannot be rescheduled or canceled.</p>')
	print('<ul id="sch-days-list">')
	for d in range(0, 7):
		print('\t<li><a href="#day-'+dayshort[d]+'">'+dayshort[d]+'</a></li>')
	print('</ul>')
	for d in range(1, 8):
		print('<h2><a name="day-'+dayshort[d-1]+'">'+dayname[d-1]+'</a></h2>')
		print('<ul>')
		for e in events:
			if d not in e['days']:
				continue
			if 'finish' in e:
				finish = ts_frac2str(e['finish'])
			else:
				finish = "night"
			sys.stdout.write('\t<li>'+ts_frac2str(e['start'])+'-'+finish+': ')
			if privacy_enabled:
				e_name = "event"
			else:
				e_name = e['name']
			if e['prio'] == "fixed":
				sys.stdout.write('<b>'+e_name+'</b>')
			else:
				sys.stdout.write(e_name)
			sys.stdout.write('</li>\n')
		print('</ul>')
	print('<p>(<a href=".">fancy version</a>)</p>')

def show_syntax():
	sys.stderr.write("Syntax: "+sys.argv[0]+" [-fmp] [-s <external-stylesheet-filename>] [-t <page-title>] [-y <type-filename>] <schedule-filename>\n")
	sys.stderr.write("\t-f\tdo not produce footnotes\n")
	sys.stderr.write("\t-m\tproduce plain output suited for mobile phones, rather than fancy output\n")
	sys.stderr.write("\t-p\tprivacy enabled: do not display event names and types\n")

footnotes_ignore = False
is_mobile = False
typefile = None
stylesheet = None
pagetitle = None
privacy_enabled = False
if len(sys.argv) < 2:
	show_syntax()
	sys.exit(1)

try:
	optlist, args = getopt.getopt(sys.argv[1:], "fmps:t:y:")
except getopt.GetoptError:
	show_syntax()
	sys.exit(1)
for o, a in optlist:
	if o == "-f":
		footnotes_ignore = True
	elif o == "-m":
		is_mobile = True
	elif o == "-p":
		privacy_enabled = True
	elif o == "-s":
		stylesheet = a
	elif o == "-t":
		pagetitle = a
	elif o == "-y":
		typefile = a
if pagetitle is None:
	pagetitle = 'Schedule'

types = {}
if typefile is not None:
	types = read_types(typefile)

config = {}
try:
	events, config = read_schedule(args[0])
except EventError as e:
	sys.stderr.write('An event in the schedule is malformed: '+str(e)+'.\n')
	sys.exit(1)
except Exception as e:
	sys.stderr.write('Could not successfully read the schedule: '+str(e)+'.\n')
	sys.exit(1)

if 'name' in config:
	pagetitle += "&mdash;"+config['name']

# Prepare input data for use
#events.sort(lambda x,y: ts_cmp(ts_frac2time(x['start']), ts_frac2time(y['start'])))
events.sort(key=lambda x: ts_frac2time(x['start']))
if privacy_enabled:
	config['time-granularity'] = config['time-granularity-pub']
else:
	config['time-granularity'] = config['time-granularity-pri']
if 'time-start' not in config:
	config['time-start'] = 0
if 'time-end' not in config:
	config['time-end'] = 23.99	# as close to the end of the day as is reasonable
# determine whether time-granularity is a divisor of one hour
div = 1.0 / config['time-granularity']
if div - int(div) > 0.00001:
	sys.stderr.write('The configured time granularity must be a divisor of one hour.\n')
	sys.exit(1)

print('''<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>''')
print('<title>'+pagetitle+'</title>')
if stylesheet is not None:
	print('<link rel="stylesheet" type="text/css" href="'+stylesheet+'"/>')
print('''
<style type="text/css">
	table#schedule {
		border: 1px outset silver;
	}

	table#schedule th.day {
		width: 7em;
	}

	table#schedule td {
		padding-left: 0.6em;
		padding-right: 0.6em;
	}

	div#sch-cats {
		float: left;
	}
	
	div#sch-foot {
		float: left;
		width: 50%;
	}
	
	ul#sch-days-list li {
		display: inline;
		margin-left: 1em;
	}

	td.empty, td.prio-flexible {
		border: 1px solid silver;
	}

	td.prio-fixed {
		border: 5px double #404040;
	}
''')
for k, v in types.items():
	sys.stdout.write('\t.cat-'+k+' {\n')
	if 'bg' in v:
		sys.stdout.write('\t\tbackground-color: '+v['bg']+';\n')
	if 'fg' in v:
		sys.stdout.write('\t\tcolor: '+v['fg']+';\n')
	sys.stdout.write('\t}\n')
print('</style>\n</head>\n<body>')
print("<h1>"+pagetitle+"</h1>")
debug_write('<p style="color:#c00000"><strong>Debug mode is enabled.</strong></p>\n')
if is_mobile:
	page_build_mobile()
else:
	page_build_full()
print('<p>This schedule was generated on '+datetime.date.today().strftime('%A, %B %d, %Y')+' by <a href="http://git.qnan.org/g/WeekScheduler">WeekScheduler</a>, a small Python program written by <a href="http://www.qnan.org/~pmw/">Philip M. White</a>.</p>')
print('</body>\n</html>\n')

