<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import {
	fillDensityGaps,
	aggregateToYears,
	clipBucketsToRange,
	selectionBoundaryFrom,
	selectionBoundaryTo,
	formatTickLabel
} from '$lib/shared/utils/monthBuckets';
import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte';
import { getLocale } from '$lib/paraglide/runtime';
import TimelineBars from '$lib/document/TimelineBars.svelte';
import TimelineYAxis from '$lib/document/TimelineYAxis.svelte';
import TimelineXAxis from '$lib/document/TimelineXAxis.svelte';
import TimelineControls from '$lib/document/TimelineControls.svelte';
import type { components } from '$lib/generated/api';

type MonthBucket = components['schemas']['MonthBucket'];
// Drag emits filter + zoom atomically (Graylog-style range selector).
// Single click and clear emit filter only — zoom fields are absent.
type SelectionEvent = {
	from: string;
	to: string;
	zoomFrom?: string | null;
	zoomTo?: string | null;
};
type ZoomEvent = { zoomFrom: string; zoomTo: string };

const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20
// Above this threshold, month bars compress to sub-pixel widths in the flex
// row; we collapse to year granularity so each bar stays clickable.
const MONTH_GRANULARITY_LIMIT = 240;

let {
	density,
	minDate,
	maxDate,
	from,
	to,
	zoomFrom = null,
	zoomTo = null,
	onchange,
	onzoomchange
}: {
	density: MonthBucket[] | null;
	minDate: string | null;
	maxDate: string | null;
	from: string;
	to: string;
	zoomFrom?: string | null;
	zoomTo?: string | null;
	onchange: (event: SelectionEvent) => void;
	onzoomchange?: (event: ZoomEvent | null) => void;
} = $props();

const monthBuckets = $derived.by(() => {
	if (density === null) return [];
	const full = fillDensityGaps(density, minDate, maxDate);
	return clipBucketsToRange(full, zoomFrom, zoomTo);
});

const filled = $derived(
	monthBuckets.length > MONTH_GRANULARITY_LIMIT ? aggregateToYears(monthBuckets) : monthBuckets
);

const isZoomed = $derived(zoomFrom !== null && zoomTo !== null);

function resetZoom() {
	onzoomchange?.(null);
}

const maxCount = $derived(Math.max(...filled.map((b) => b.count), 1));

const hasSelection = $derived(from !== '' || to !== '');

let rowEl: HTMLDivElement | undefined = $state();

function clearSelection() {
	onchange({ from: '', to: '' });
}

function isSelected(label: string): boolean {
	if (!hasSelection) return false;
	const labelFrom = selectionBoundaryFrom(label);
	return labelFrom >= from && labelFrom <= to;
}

function isYearLabel(label: string): boolean {
	return label.length === 4;
}

function emitSelection(startIndex: number, endIndex: number, includeZoom: boolean) {
	const lo = Math.min(startIndex, endIndex);
	const hi = Math.max(startIndex, endIndex);
	const startLabel = filled[lo]?.month;
	const endLabel = filled[hi]?.month;
	if (!startLabel || !endLabel) return;
	const selFrom = selectionBoundaryFrom(startLabel);
	const selTo = selectionBoundaryTo(endLabel);
	if (includeZoom) {
		onchange({ from: selFrom, to: selTo, zoomFrom: selFrom, zoomTo: selTo });
	} else {
		onchange({ from: selFrom, to: selTo });
	}
}

// Maps a viewport X-coordinate to a bar index by measuring the row, so
// pointermove during drag works even when the cursor leaves the original bar
// or the graph entirely. Pointer capture isn't usable here because it would
// re-target click and suppress pointerenter on sibling bars.
function indexFromClientX(clientX: number): number | null {
	if (!rowEl || filled.length === 0) return null;
	const rect = rowEl.getBoundingClientRect();
	const x = clientX - rect.left;
	if (x < 0) return 0;
	if (x >= rect.width) return filled.length - 1;
	const barWidth = rect.width / filled.length;
	return Math.min(filled.length - 1, Math.max(0, Math.floor(x / barWidth)));
}

const drag = createTimelineDrag({
	indexFromClientX,
	labelAt: (i) => filled[i]?.month,
	isYearLabel,
	emit: emitSelection
});

// Strip any in-flight document listeners if the component unmounts mid-drag
// (route change, view toggle, breakpoint drop). Without this they survive on
// document and keep writing to torn-down state cells.
$effect(() => drag.cleanup);

function isInDragPreview(index: number): boolean {
	if (!drag.isDragging) return false;
	if (drag.lowIndex === null || drag.highIndex === null) return false;
	return index >= drag.lowIndex && index <= drag.highIndex;
}

const dragWindowLeftPct = $derived.by(() => {
	if (!drag.isDragging || drag.lowIndex === null || filled.length === 0) return 0;
	return (drag.lowIndex / filled.length) * 100;
});
const dragWindowRightPct = $derived.by(() => {
	if (!drag.isDragging || drag.highIndex === null || filled.length === 0) return 100;
	return ((filled.length - drag.highIndex - 1) / filled.length) * 100;
});

// While dragging, expose the live preview range to assistive tech via a
// polite live region. Empty text outside drag avoids announcing residual state.
const dragLiveMessage = $derived.by(() => {
	if (!drag.isDragging || drag.lowIndex === null || drag.highIndex === null) return '';
	const fromLabel = filled[drag.lowIndex]?.month;
	const toLabel = filled[drag.highIndex]?.month;
	if (!fromLabel || !toLabel) return '';
	return m.timeline_dragging_aria_live({
		from: formatTickLabel(fromLabel, getLocale()),
		to: formatTickLabel(toLabel, getLocale())
	});
});
</script>

{#if density !== null}
	<div
		data-testid="timeline-density-filter"
		role="group"
		aria-label={m.timeline_aria_label()}
		class="relative rounded-sm border border-line bg-surface px-3 pt-3 pb-2 shadow-sm"
	>
		<div class="flex">
			<TimelineYAxis maxCount={maxCount} barAreaHeight={BAR_AREA_HEIGHT} />

			<div class="flex-1">
				<TimelineBars
					filled={filled}
					maxCount={maxCount}
					barAreaHeight={BAR_AREA_HEIGHT}
					isSelected={isSelected}
					isInDragPreview={isInDragPreview}
					isDragging={drag.isDragging}
					dragWindowLeftPct={dragWindowLeftPct}
					dragWindowRightPct={dragWindowRightPct}
					bind:rowEl={rowEl}
					onbarpointerdown={drag.pointerDown}
					onbarpointerenter={drag.pointerEnter}
					onbarclick={drag.click}
				/>

				<TimelineXAxis filled={filled} />
			</div>
		</div>

		<div class="sr-only" aria-live="polite" data-testid="timeline-aria-live">
			{dragLiveMessage}
		</div>

		<TimelineControls
			isZoomed={isZoomed}
			hasSelection={hasSelection}
			onresetzoom={resetZoom}
			onclearselection={clearSelection}
		/>
	</div>
{/if}
