<template>
	<figure class="timetable">
		<details>
			<summary>Debug</summary>
			<details>
				<summary><b>Junctions</b></summary>
				<div v-for="j in junctions" :key="j.ts">
					{{ j.ts }}: {{ j.edges.map(e => `${e.type} ${e.slot.id}`).join(", ") }}
				</div>
			</details>
			<details>
				<summary><b>Stretches</b></summary>
				<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>
			<p>
				<label>
					Timezone:
					<input type="text" v-model="timezone">
				</label>
			</p>
		</details>
		<table>
			<colgroup>
				<col class="header" />
			</colgroup>
			<colgroup v-for="group, groupIndex in columnGroups" :key="groupIndex" :class="{ break: group.isBreak }">
				<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"
						:class="{ break: day.isBreak }"
					>
						{{ day.content }}
					</th>
				</tr>
				<tr class="hours">
					<th>Location</th>
					<th
						v-for="hour, index in hourHeaders"
						:key="index"
						:colSpan="hour.span"
						:class="{ break: hour.isBreak, dayShift: hour.isDayShift }"
					>
						<div v-if="hour.content">
							{{ hour.content }}
						</div>
					</th>
				</tr>
			</thead>
			<tbody>
				<tr class="overlay">
					<th></th>
					<td :colSpan="totalColumns">
						<div
							v-if="nowOffset !== undefined"
							class="now"
							:style="` --now-offset: ${nowOffset}`"
						>
							<div class="label">
								now
							</div>
						</div>
					</td>
				</tr>
				<template v-for="[id, locationGroup] in locationGroups" :key="id">
					<tr
						v-for="row, index in locationGroup"
						:key="index"
					>
						<th
							v-if="index === 0"
							:rowSpan="locationGroup.length"
						>
							{{ schedule.locations.get(id!)?.name }}
						</th>
						<td
							v-for="cell, index in row"
							:key="index"
							:colSpan="cell.span"
							:class='{"event": cell.slot, "crew": cell.event?.crew }'
							:title="cell.event?.name"
						>
							{{ cell.event?.name }}
						</td>
					</tr>
				</template>
				<template v-if="roleGroups.size">
					<tr>
						<th>Shifts</th>
						<td :colSpan="totalColumns"></td>
					</tr>
					<template v-for="[id, roleGroup] in roleGroups" :key="id">
						<tr
							v-for="row, index in roleGroup"
							:key="index"
						>
							<th
								v-if="index === 0"
								:rowSpan="roleGroup.length"
							>
								{{ schedule.roles.get(id!)?.name }}
							</th>
							<td
								v-for="cell, index in row"
								:key="index"
								:colSpan="cell.span"
								:class='{"shift": cell.slot }'
								:title="cell.shift?.name"
							>
								{{ cell.shift?.name }}
							</td>
						</tr>
					</template>
				</template>
			</tbody>
		</table>
	</figure>
</template>

<script setup lang="ts">
import { DateTime } from "~/shared/utils/luxon";
import type { Id } from "~/shared/types/common";
import { pairs, setEquals } from "~/shared/utils/functions";

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", source: "event", slot: ClientScheduleEventSlot }
	| { type: "start" | "end", source: "shift", roleId?: Id, slot: ClientScheduleShiftSlot }
;

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

/** Span of time between two adjacent junctions */
type Span = {
	start: Junction;
	end: Junction,
	locations: Map<number | undefined, Set<ClientScheduleEventSlot>>,
	roles: Map<number | undefined, Set<ClientScheduleShiftSlot>>,
};

/**
	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: number,
	end: number,
	spans: Span[];
}

function* edgesFromEvents(
	events: Iterable<ClientScheduleEvent>,
	filter = (slot: ClientScheduleEventSlot) => true,
): Generator<Edge> {
	for (const event of events) {
		if (event.deleted)
			continue;
		for (const slot of event.slots.values()) {
			if (!filter(slot) || slot.deleted)
				continue;
			if (slot.start > slot.end) {
				throw new Error(`Slot ${slot.id} ends before it starts.`);
			}
			yield { type: "start", source: "event", slot }
			yield { type: "end", source: "event", slot }
		}
	}
}

function* edgesFromShifts(
	shifts: Iterable<ClientScheduleShift>,
	filter = (slot: ClientScheduleShiftSlot) => true,
): Generator<Edge> {
	for (const shift of shifts) {
		if (shift.deleted)
			continue;
		for (const slot of shift.slots.values()) {
			if (!filter(slot) || slot.deleted)
				continue;
			if (slot.start > slot.end) {
				throw new Error(`Slot ${slot.id} ends before it starts.`);
			}
			yield { type: "start", source: "shift", roleId: shift.roleId, slot };
			yield { type: "end", source: "shift", roleId: shift.roleId, slot };
		}
	}
}

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

function* spansFromJunctions(
	junctions: Iterable<Junction>,
	locations: ClientMap<ClientScheduleLocation>,
	roles: ClientMap<ClientScheduleRole>,
): Generator<Span> {
	const activeLocations = new Map<number | undefined, Set<ClientScheduleEventSlot>>(
		[...locations.keys()].map(id => [id, new Set()])
	);
	activeLocations.set(undefined, new Set());
	const activeRoles = new Map<number | undefined, Set<ClientScheduleShiftSlot>>(
		[...roles.keys()].map(id => [id, new Set()]),
	);
	activeRoles.set(undefined, new Set());
	for (const [start, end] of pairs(junctions)) {
		for (const edge of start.edges) {
			if (edge.type === "start") {
				if (edge.source === "event") {
					for (const locationId of edge.slot.locationIds) {
						activeLocations.get(locationId)?.add(edge.slot);
					}
					if (edge.slot.locationIds.size === 0) {
						activeLocations.get(undefined)?.add(edge.slot);
					}
				} else if (edge.source === "shift") {
					activeRoles.get(edge.roleId)?.add(edge.slot);
				}
			}
		}
		yield {
			start,
			end,
			locations: new Map(
				[...activeLocations]
					.filter(([_, slots]) => slots.size)
					.map(([location, slots]) => [location, new Set(slots)])
			),
			roles: new Map(
				[...activeRoles]
					.filter(([_, slots]) => slots.size)
					.map(([role, slots]) => [role, new Set(slots)])
			),
		}
		for (const edge of end.edges) {
			if (edge.type === "end") {
				if (edge.source === "event") {
					for (const locationId of edge.slot.locationIds) {
						activeLocations.get(locationId)?.delete(edge.slot);
					}
					if (edge.slot.locationIds.size === 0) {
						activeLocations.get(undefined)?.delete(edge.slot);
					}
				} else if (edge.source === "shift") {
					activeRoles.get(edge.roleId)?.delete(edge.slot);
				}
			}
		}
	}
}

function createStretch(spans: Span[]): Stretch {
	return {
		spans,
		start: spans[0].start.ts,
		end: spans[spans.length - 1].end.ts,
	}
}

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
			&& span.roles.size === 0
			&& span.end.ts - 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, timezone: string): Generator<Span> {
	const startHour = DateTime.fromMillis(span.start.ts, { zone: timezone, locale: accountStore.activeLocale })
		.startOf("hour")
	;
	const end = span.end.ts;

	let currentStart = startHour;
	let currentEnd = startHour.plus({ hours: 1 });
	if (!startHour.isValid || currentEnd.toMillis() >= end) {
		yield span;
		return;
	}

	yield {
		start: span.start,
		end: { ts: currentEnd.toMillis(), edges: [] },
		locations: span.locations,
		roles: span.roles,
	}

	while (true) {
		currentStart = currentEnd;
		currentEnd = currentEnd.plus({ hours: 1 });
		if (currentEnd.toMillis() >= end) {
			break;
		}
		yield {
			start: { ts: currentStart.toMillis(), edges: [] },
			end: { ts: currentEnd.toMillis(), edges: [] },
			locations: span.locations,
			roles: span.roles,
		}
	}

	yield {
		start: { ts: currentStart.toMillis(), edges: [] },
		end: span.end,
		locations: span.locations,
		roles: span.roles,
	}
}

function padStretch(stretch: Stretch, timezone: string): Stretch {
	// Pad by one hour and extend it to the nearest whole hour.
	let start = DateTime.fromMillis(stretch.start, { zone: timezone, locale: accountStore.activeLocale })
		.minus(oneHourMs)
		.startOf("hour")
	;
	let end = DateTime.fromMillis(stretch.end, { zone: timezone, locale: accountStore.activeLocale })
		.plus(2 * oneHourMs - 1)
		.startOf("hour")
	;
	return {
		spans: [
			{
				start: { ts: start.toMillis(), edges: [] },
				end: stretch.spans[0].start,
				locations: new Map(),
				roles: new Map(),
			},
			...stretch.spans,
			{
				start: stretch.spans[stretch.spans.length - 1].end,
				end: { ts: end.toMillis(), edges: [] },
				locations: new Map(),
				roles: new Map(),
			},
		],
		start: start.toMillis(),
		end: end.toMillis(),
	}
}

function tableElementsFromStretches(
	stretches: Iterable<Stretch>,
	events: ClientMap<ClientScheduleEvent>,
	locations: ClientMap<ClientScheduleLocation>,
	shifts: ClientMap<ClientScheduleShift>,
	roles: ClientMap<ClientScheduleRole>,
	timezone: string,
) {
	type Col = { minutes?: number };
	type DayHead = { span: number, isBreak: boolean, content?: string }
	type HourHead = { span: number, isBreak: boolean, isDayShift: boolean, content?: string }
	type LocationCell = { span: number, isBreak: boolean, slot?: ClientScheduleEventSlot, event?: ClientScheduleEvent };
	type LocationRow = LocationCell[];
	type RoleCell = { span: number, isBreak: boolean, slot?: ClientScheduleShiftSlot, shift?: ClientScheduleShift };
	type RoleRow = RoleCell[];
	type ColumnGroup = { start: number, end: number, width: number, isBreak: boolean, cols: Col[] };
	const columnGroups: ColumnGroup[] = [];
	const dayHeaders: DayHead[] = [];
	const hourHeaders: HourHead[]= [];
	const locationGroups = new Map<number | undefined, LocationRow[]>([...locations.keys()].map(id => [id, []]));
	locationGroups.set(undefined, []);
	const roleGroups = new Map<number | undefined, RoleRow[]>([...roles.keys()].map(id => [id, []]));
	roleGroups.set(undefined, []);
	const eventBySlotId = new Map([...events.values()].flatMap(event => [...event.slots.values()].map(slot => [slot.id, event])));
	const shiftBySlotId = new Map([...shifts.values()].flatMap?.(shift => [...shift.slots.values()].map(slot =>[slot.id, shift])));
	let totalColumns = 0;

	function startColumnGroup(start: number, end: number, width: number, isBreak: boolean) {
		columnGroups.push({ start, end, width, isBreak, cols: []})
	}
	function startDay(isBreak: boolean, content?: string) {
		dayHeaders.push({ span: 0, isBreak, content })
	}
	function startHour(isBreak: boolean, content?: string, isDayShift = false) {
		hourHeaders.push({ span: 0, isBreak, isDayShift, content })
	}
	function startLocation(id: number | undefined, isBreak: boolean, newSlots = new Set<ClientScheduleEventSlot>()) {
		const group = locationGroups.get(id)!;
		// Remove all slots that are no longer in the new slots.
		for (const row of group) {
			const cell = row[row.length - 1];
			if (cell.isBreak !== isBreak || cell.slot && !newSlots.has(cell.slot))
				row.push({ span: 0, isBreak, slot: undefined, event: undefined });
		}
		const existingSlots = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
		// Add all new slots that do not already exist.
		for (const slot of newSlots.difference(existingSlots)) {
			let row = group.find(row => !row[row.length - 1].slot);
			if (!row) {
				row = columnGroups.map(
					colGroup => ({
						span: colGroup.cols.length,
						isBreak: colGroup.isBreak,
						slot: undefined,
						event: undefined
					})
				);
				group.push(row);
			}
			row.push({ span: 0, isBreak, slot, event: eventBySlotId.get(slot.id) });
		}
	}
	function startRole(id: number | undefined, isBreak: boolean, newSlots = new Set<ClientScheduleShiftSlot>()) {
		const group = roleGroups.get(id)!;
		// Remove all slots that are no longer in the new slots.
		for (const row of group) {
			const cell = row[row.length - 1];
			if (cell.isBreak !== isBreak || cell.slot && !newSlots.has(cell.slot)) {
				row.push({ span: 0, isBreak, slot: undefined, shift: undefined });
			}
		}
		const existingSlots = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
		// Add all new slots that do not already exist.
		for (const slot of newSlots.difference(existingSlots)) {
			let row = group.find(row => !row[row.length - 1].slot);
			if (!row) {
				row = columnGroups.map(
					colGroup => ({
						span: colGroup.cols.length,
						isBreak: colGroup.isBreak,
						slot: undefined,
						shift: undefined
					})
				);
				group.push(row);
			}
			row.push({ span: 0, isBreak, slot, shift: shiftBySlotId.get(slot.id) });
		}
	}
	function pushColumn(minutes?: number) {
		totalColumns += 1;
		columnGroups[columnGroups.length - 1].cols.push({ minutes })
		dayHeaders[dayHeaders.length - 1].span += 1;
		hourHeaders[hourHeaders.length - 1].span += 1;
		for (const locationGroup of locationGroups.values()) {
			for (const row of locationGroup) {
				row[row.length - 1].span += 1;
			}
		}
		for (const roleGroup of roleGroups.values()) {
			for (const row of roleGroup) {
				row[row.length - 1].span += 1;
			}
		}
	}

	let lastStretch: Stretch | undefined;
	for (let stretch of stretches) {
		stretch = padStretch(stretch, timezone);
		const startDate = DateTime.fromMillis(stretch.start, { zone: timezone, locale: accountStore.activeLocale });
		if (!lastStretch) {
			startColumnGroup(stretch.start, stretch.end, (stretch.end - stretch.start) / oneHourMs, false);
			startDay(false, startDate.toFormat("yyyy-LL-dd"));
			startHour(false, startDate.toFormat("HH:mm"));
			for (const locationId of locationGroups.keys()) {
				startLocation(locationId, false);
			}
			for (const roleId of roleGroups.keys()) {
				startRole(roleId, false);
			}
		} else {
			startColumnGroup(lastStretch.end, stretch.start, 1, true);
			const dayName = startDate.toFormat("yyyy-LL-dd");
			const lastDayHeader = dayHeaders[dayHeaders.length - 1]
			const sameDay = dayName === lastDayHeader.content && lastDayHeader.span;
			if (!sameDay)
				startDay(true);
			startHour(true, "break");
			for (const locationId of locationGroups.keys()) {
				startLocation(locationId, true);
			}
			for (const roleId of roleGroups.keys()) {
				startRole(roleId, true);
			}
			pushColumn();

			startColumnGroup(stretch.start, stretch.end, (stretch.end - stretch.start) / oneHourMs, false);
			if (!sameDay)
				startDay(false, dayName);
			startHour(false, startDate.toFormat("HH:mm"));
			for (const locationId of locationGroups.keys()) {
				startLocation(locationId, false);
			}
			for (const roleId of roleGroups.keys()) {
				startRole(roleId, false);
			}
		}

		for (const span of stretch.spans) {
			for (const cutSpan of cutSpansByHours(span, timezone)) {
				const end = cutSpan.end.ts;
				const durationMs = end - cutSpan.start.ts;

				for (const locationId of locationGroups.keys()) {
					const slots = cutSpan.locations.get(locationId) ?? new Set();
					const group = locationGroups.get(locationId)!;
					const existing = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
					if (!setEquals(slots, existing)) {
						startLocation(locationId, false, slots);
					}
				}
				for (const roleId of roleGroups.keys()) {
					const slots = cutSpan.roles.get(roleId) ?? new Set();
					const group = roleGroups.get(roleId)!;
					const existing = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
					if (!setEquals(slots, existing)) {
						startRole(roleId, false, slots);
					}
				}

				pushColumn(durationMs / oneMinMs);
				const endDate = DateTime.fromMillis(end, { zone: timezone, locale: accountStore.activeLocale });
				const isDayShift = end === endDate.startOf("day").toMillis();
				if (isDayShift) {
					startDay(
						false,
						DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: accountStore.activeLocale })
							.toFormat("yyyy-LL-dd")
					);
				}
				if (end === endDate.startOf("hour").toMillis()) {
					startHour(
						false,
						DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: accountStore.activeLocale })
							.toFormat("HH:mm"),
						isDayShift,
					);
				}
			}
		}

		lastStretch = stretch;
	}

	return {
		totalColumns,
		columnGroups,
		dayHeaders: dayHeaders.filter(day => day.span),
		hourHeaders: hourHeaders.filter(hour => hour.span),
		locationGroups: new Map([...locationGroups]
			.filter(([_, rows]) => rows.length)
			.map(([id, rows]) => [
				id, rows.map(row => row.filter(cell => cell.span))
			]))
		,
		roleGroups: new Map([...roleGroups]
			.filter(([_, rows]) => rows.length)
			.map(([id, rows]) => [
				id, rows.map(row => row.filter(cell => cell.span))
			]))
		,
		eventBySlotId,
	};
}

const props = defineProps<{
	schedule: ClientSchedule,
	eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
	shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
}>();
const schedule = computed(() => props.schedule);
const junctions = computed(() => {
	return junctionsFromEdges([
		...edgesFromEvents(schedule.value.events.values(), props.eventSlotFilter),
		...edgesFromShifts(schedule.value.shifts.values(), props.shiftSlotFilter),
	])
});
const stretches = computed(() => {
	return [
		...stretchesFromSpans(
			spansFromJunctions(
				junctions.value,
				schedule.value.locations,
				schedule.value.roles,
			),
			oneHourMs * 5
		)
	]
})

const accountStore = useAccountStore();
const timezone = computed({
	get: () => accountStore.activeTimezone,
	set: (value: string) => { accountStore.timezone = value },
});

const elements = computed(() => {
	return tableElementsFromStretches(
		stretches.value,
		schedule.value.events,
		schedule.value.locations,
		schedule.value.shifts,
		schedule.value.roles,
		accountStore.activeTimezone
	);
});
const totalColumns = computed(() => elements.value.totalColumns);
const columnGroups = computed(() => elements.value.columnGroups);
const dayHeaders = computed(() => elements.value.dayHeaders);
const hourHeaders = computed(() => elements.value.hourHeaders);
const locationGroups = computed(() => elements.value.locationGroups);
const roleGroups = computed(() => elements.value.roleGroups);

const now = useState(() => Math.round(Date.now() / oneMinMs) * oneMinMs);
const interval = ref<any>();
onMounted(() => {
	interval.value = setInterval(() => {
		const newNow = Math.round(Date.now() / oneMinMs) * oneMinMs;
		if (now.value !== newNow)
			now.value = newNow;
	}, 1000);
});
onUnmounted(() => {
	clearInterval(interval.value);
});
const nowOffset = computed(() => {
	let offset = 0;
	for (let group of columnGroups.value) {
		if (group.start <= now.value && now.value < group.end) {
			return offset + (now.value - group.start) / (group.end - group.start) * group.width;
		}
		offset += group.width;
	}
});
</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 tr:not(.overlay) :is(td, th) {
	overflow: hidden;
	white-space: pre;
	text-overflow: ellipsis;
	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);
}

.timetable tbody {
	position: relative;
}
.timetable tr.overlay .now {
	background-color: #f008;
	position: absolute;
	top: 0;
	left: calc(var(--row-header-width) + var(--cell-size) * var(--now-offset) - 1px);
	bottom: 0;
	width: 2px;
	scroll-margin-inline-start: calc(var(--row-header-width) + 2rem);
}
.now .label {
	position: absolute;
	top: 0;
	color: white;
	background: red;
	border-radius: 0.2rem;
	font-size: 0.5rem;
	line-height: 1.1;
	padding-inline: 0.1rem;
	translate: calc(-50% + 0.5px) -50%;
}

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

tr.hours>th:is(.break, :first-child) + th div {
	visibility: hidden;
}

tr.hours>th:first-child {
	z-index: 1;
}
tr.hours>th + th:not(.break) {
	overflow: visible;
	padding-top: 0;
	vertical-align: top;
}
tr.hours>th + th:not(.break) div {
	font-variant-numeric: tabular-nums;
	padding-top: 0.2rem;
	background-color: Canvas;
	translate: calc(-0.5 * var(--cell-size)) 0;
}
tr.hours>th + th.dayShift div {
	padding-top: 0;
	margin-top: 0.2rem;
}

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