<template>
	<figure class="timetable">
		<details>
			<summary>Debug</summary>
			<p><b>Junctions</b></p>
			<div v-for="j in junctions" :key="j.ts">
				{{ j.ts }}: {{ j.edges.map(e => `${e.type} ${e.slot.id}`).join(", ") }}
			</div>
			<p><b>Stretches</b></p>
			<ol>
				<li v-for="st in stretches" :key="st.start">
					<p>Stretch from {{ st.start }} to {{ st.end }}.</p>
					<p>Spans:</p>
					<ul>
						<li v-for="s in st.spans" :key="s.start.ts">
							{{ s.start.ts }} - {{ s.end.ts }}:
							<ul>
								<li v-for="[id, slots] in s.locations" :key="id">
									{{ id }}: {{ [...slots].map(s => s.id).join(", ") }}
								</li>
							</ul>
						</li>
					</ul>
				</li>
			</ol>
		</details>
		<table>
			<colgroup>
				<col class="header" />
			</colgroup>
			<colgroup v-for="group, groupIndex in columnGroups" :key="groupIndex" :class="group.className">
				<col v-for="col, index in group.cols" :key="index" :style='{"--minutes": col.minutes}' />
			</colgroup>
			<thead>
				<tr>
					<th></th>
					<th v-for="day, index in dayHeaders" :key="index" :colSpan="day.span">
						{{ day.content }}
					</th>
				</tr>
				<tr>
					<th>Location</th>
					<th v-for="hour, index in hourHeaders" :key="index" :colSpan="hour.span">
						{{ hour.content }}
					</th>
				</tr>
			</thead>
			<tbody>
				<tr v-for="location in schedule.locations" :key="location.id">
					<th>{{ location.name }}</th>
					<td
						v-for="row, index in locationRows.get(location.id)"
						:key="index"
						:colSpan="row.span"
						:class='{"event": row.slots.size }'
					>
						{{ [...row.slots].map(slot => eventBySlotId.get(slot.id)!.name).join(", ") }}
					</td>
				</tr>
			</tbody>
		</table>
	</figure>
</template>

<script setup lang="ts">
import type { ScheduleEvent, ScheduleLocation, TimeSlot } from "~/shared/types/schedule";

const oneDayMs = 24 * 60 * 60 * 1000;
const oneHourMs = 60 * 60 * 1000;
const oneMinMs = 60 * 1000;

// See timetable-terminology.png for an illustration of how these terms are related

/** Point in time where a time slots starts or ends. */
type Edge = { type: "start" | "end", slot: TimeSlot };

/** Point in time where multiple edges meet. */
type Junction = { ts: string, edges: Edge[] };

/** Span of time between two adjacent junctions */
type Span = {
	start: Junction;
	end: Junction,
	locations: Map<string, Set<TimeSlot>>,
};

/**
	Collection of adjacent spans containing TimeSlots that are close to each
	other in time. The start and end of the stretch is aligned to a whole hour
	and the endpoint spans are always empty and at least one hour.
*/
type Stretch = {
	start: string,
	end: string,
	spans: Span[];
}

/** Returns a tuple consisting of a running index starting from 0, and the item of the iterable */
function* enumerate<T>(iterable: Iterable<T>) {
	let index = 0;
	for (const item of iterable) {
		yield [index++, item] as [number, T];
	}
}

/** Returns adjacent pairs from iterable */
function* pairs<T>(iterable: Iterable<T>) {
	let first;
	let second;
	for (const [index, item] of enumerate(iterable)) {
		[first, second] = [second, item];
		if (index >= 1) {
			yield [first, second] as [T, T];
		}
	}
}

/**
	Returns true if all sets are equal
	@param sets set to compare
	@returns true if all sets are the same size and have the same elements
*/
function setEquals<T>(...sets: Set<T>[]) {
	if (sets.length < 2) {
		throw TypeError("At least two sets must be passed to setEquals");
	}
	const ref = sets[0];
	const rest = sets.slice(1);
	if (rest.some(set => set.size !== ref.size)) {
		return false;
	}
	for (const set of rest) {
		for (const el of set) {
			if (!ref.has(el)) {
				return false;
			}
		}
	}
	return true;
}

function isoStringFromTs(ts: number) {
	return new Date(ts).toISOString().replace(":00.000Z", "Z");
}

function* edgesFromEvents(events: Iterable<ScheduleEvent>): Generator<Edge> {
	for (const event of events) {
		for (const slot of event.slots) {
			if (slot.start > slot.end) {
				throw new Error(`Slot ${slot.id} ends before it starts.`);
			}
			yield { type: "start", slot }
			yield { type: "end", slot }
		}
	}
}

function junctionsFromEdges(edges: Iterable<Edge>) {
	const junctions = new Map<string, Junction>();
	for (const edge of edges) {
		const ts = edge.slot[edge.type];
		const junction = junctions.get(ts);
		if (junction) {
			junction.edges.push(edge);
		} else {
			junctions.set(ts, { ts, edges: [edge] });
		}
	}
	const keys = [...junctions.keys()].sort();
	return keys.map(key => junctions.get(key)!);
}

function* spansFromJunctions(
	junctions: Iterable<Junction>, locations: ScheduleLocation[]
): Generator<Span> {
	const activeLocations = new Map(
		locations.map(location => [location.id, new Set<TimeSlot>()])
	);
	for (const [start, end] of pairs(junctions)) {
		for (const edge of start.edges) {
			if (edge.type === "start") {
				for (const location of edge.slot.locations) {
					activeLocations.get(location)!.add(edge.slot)
				}
			}
		}
		yield {
			start,
			end,
			locations: new Map(
				[...activeLocations]
					.filter(([_, slots]) => slots.size)
					.map(([location, slots]) => [location, new Set(slots)])
			),
		}
		for (const edge of end.edges) {
			if (edge.type === "end") {
				for (const location of edge.slot.locations) {
					activeLocations.get(location)!.delete(edge.slot)
				}
			}
		}
	}
}

function createStretch(spans: Span[]): Stretch {
	let startTs = Date.parse(spans[0].start.ts) - oneHourMs;
	let endTs = Date.parse(spans[spans.length - 1].end.ts) + oneHourMs;
	// Extend stretch to nearest whole hours
	startTs = Math.floor(startTs / oneHourMs) * oneHourMs;
	endTs = Math.ceil(endTs / oneHourMs) * oneHourMs;
	// Convert back to ISO date string
	let start = isoStringFromTs(startTs);
	let end = isoStringFromTs(endTs);
	return {
		spans: [
			{
				start: { ts: start, edges: [] },
				end: spans[0].start,
				locations: new Map(),
			},
			...spans,
			{
				start: spans[spans.length - 1].end,
				end: { ts: end, edges: [] },
				locations: new Map(),
			},
		],
		start,
		end,
	}
}

function* stretchesFromSpans(spans: Iterable<Span>, minSeparation: number): Generator<Stretch> {
	let currentSpans: Span[] = [];
	for (const span of spans) {
		// Based on how spans are generated I can assume that an empty span
		// will only occur between two spans with timeslots in them.
		if (span.locations.size === 0
			&& Date.parse(span.end.ts) - Date.parse(span.start.ts) >= minSeparation
		) {
			yield createStretch(currentSpans);
			currentSpans = [];
		} else {
			currentSpans.push(span);
		}
	}
	if (currentSpans.length)
		yield createStretch(currentSpans);
}

/** Cuts up a span by whole hours that crosses it */
function* cutSpansByHours(span: Span): Generator<Span> {
	const startHour = Date.parse(span.start.ts) / oneHourMs;
	const endHour = Date.parse(span.end.ts) / oneHourMs;
	let currentStart = startHour;
	let currentEnd = Math.min(Math.floor(startHour + 1), endHour);
	if (currentEnd === endHour) {
		yield span;
		return;
	}

	yield {
		start: span.start,
		end: { ts: isoStringFromTs(currentEnd * oneHourMs), edges: [] },
		locations: span.locations,
	}

	currentStart = currentEnd;
	while (++currentEnd < endHour) {
		yield {
			start: { ts: isoStringFromTs(currentStart * oneHourMs), edges: [] },
			end: { ts: isoStringFromTs(currentEnd * oneHourMs), edges: [] },
			locations: span.locations,
		}
		currentStart += 1;
	}

	yield {
		start: { ts: isoStringFromTs(currentStart * oneHourMs), edges: [] },
		end: span.end,
		locations: span.locations,
	}
}

function tableElementsFromStretches(
	stretches: Iterable<Stretch>, locations: ScheduleLocation[]
) {
	type Col = { minutes?: number };
	type DayHead = { span: number, content?: string }
	type HourHead = { span: number, content?: string }
	type LocationCell = { span: number, slots: Set<TimeSlot> }
	const columnGroups: { className?: string, cols: Col[] }[] = [];
	const dayHeaders: DayHead[] = [];
	const hourHeaders: HourHead[]= [];
	const locationRows = new Map<string, LocationCell[]>(locations.map(location => [location.id, []]));

	function startColumnGroup(className?: string) {
		columnGroups.push({ className, cols: []})
	}
	function startDay(content?: string) {
		dayHeaders.push({ span: 0, content })
	}
	function startHour(content?: string) {
		hourHeaders.push({ span: 0, content })
	}
	function startLocation(id: string, slots = new Set<TimeSlot>()) {
		locationRows.get(id)!.push({ span: 0, slots });
	}
	function pushColumn(minutes?: number) {
		columnGroups[columnGroups.length - 1].cols.push({ minutes })
		dayHeaders[dayHeaders.length - 1].span += 1;
		hourHeaders[hourHeaders.length - 1].span += 1;
		for(const location of locations) {
			const row = locationRows.get(location.id)!;
			row[row.length - 1].span += 1;
		}
	}

	let first = true;
	for (const stretch of stretches) {
		if (first) {
			first = false;
			startColumnGroup();
			startDay(stretch.start.slice(0, 10));
			startHour(stretch.start.slice(11, 16));
			for(const location of locations) {
				startLocation(location.id);
			}
		} else {
			startColumnGroup("break");
			const dayName = stretch.start.slice(0, 10)
			const sameDay = dayName === dayHeaders[dayHeaders.length - 1].content;
			if (!sameDay)
				startDay();
			startHour("break");
			for(const location of locations) {
				startLocation(location.id);
			}
			pushColumn();

			startColumnGroup();
			if (!sameDay)
				startDay(dayName);
			startHour(stretch.start.slice(11, 16));
			for(const location of locations) {
				startLocation(location.id);
			}
		}

		for (const span of stretch.spans) {
			for (const cutSpan of cutSpansByHours(span)) {
				const startTs = Date.parse(cutSpan.start.ts);
				const endTs = Date.parse(cutSpan.end.ts);
				const durationMs = endTs - startTs;

				for (const location of locations) {
					const rows = locationRows.get(location.id)!;
					const row = rows[rows.length - 1];
					const slots = cutSpan.locations.get(location.id) ?? new Set();
					if (!setEquals(slots, row.slots)) {
						startLocation(location.id, slots);
					}
				}

				pushColumn(durationMs / oneMinMs);
				if (endTs % oneDayMs === 0) {
					startDay(cutSpan.end.ts.slice(0, 10));
				}
				if (endTs % oneHourMs === 0) {
					startHour(cutSpan.end.ts.slice(11, 16));
				}
			}
		}
	}

	return {
		columnGroups,
		dayHeaders: dayHeaders.filter(day => day.span),
		hourHeaders: hourHeaders.filter(hour => hour.span),
		locationRows: new Map([...locationRows].map(([id, cells]) => [id, cells.filter(cell => cell.span)])),
	};
}

const schedule = useSchedule();
const junctions = computed(() => junctionsFromEdges(edgesFromEvents(schedule.value.events)));
const stretches = computed(() => [
	...stretchesFromSpans(spansFromJunctions(junctions.value, schedule.value.locations), oneHourMs * 5)
])
const elements = computed(() => tableElementsFromStretches(stretches.value, schedule.value.locations));
const columnGroups = computed(() => elements.value.columnGroups);
const dayHeaders = computed(() => elements.value.dayHeaders);
const hourHeaders = computed(() => elements.value.hourHeaders);
const locationRows = computed(() => elements.value.locationRows);
const eventBySlotId = computed(() => new Map(
	schedule.value.events.flatMap(
		event => event.slots.map(slot => [slot.id, event])
	)
));
</script>

<style scoped>
.timetable {
	overflow-x: auto;
}

.timetable table {
	border-spacing: 0;
	table-layout: fixed;
	width: 100%;
	font-size: 0.8rem;
	--row-header-width: 6rem;
	--cell-size: 3rem;
}

.timetable col {
	width: calc(var(--cell-size) * var(--minutes, 60) / 60);
}

.timetable col.header {
	width: var(--row-header-width);
}

.timetable th:first-child {
	background-color: var(--background);
	position: sticky;
	left: 0;
}

.timetable :is(td, th) {
	padding: 0.1rem;
	border-top: 1px solid var(--foreground);
	border-right: 1px solid var(--foreground);
}
.timetable tr th:first-child {
	border-left: 1px solid var(--foreground);
}
.timetable tbody tr:last-child :is(td, th) {
	border-bottom: 1px solid var(--foreground);
}

.break {
	background-color: color-mix(in oklab, var(--background), rgb(50, 50, 255) 60%);
}

.event {
	background-color: color-mix(in oklab, var(--background), rgb(255, 125, 50) 60%);
}
</style>
