<script lang="ts">
	/**
	 * Progress element renderer. Three shapes (bar-horizontal, bar-vertical,
	 * ring) share the same `value`, `max`, fill, and label model. The render
	 * shape branches on `element.shape`; everything else (animation, label
	 * formatting, fill resolution) is shape-agnostic.
	 *
	 * Animation:
	 *   • On slide entrance: tween from 0 → target over `animationDuration`.
	 *   • On slide-to-slide morph: caller passes `previousElement` (same id
	 *     on prior slide) and we tween from prior `value` to current. Mirrors
	 *     the chart morph pattern — caller in /present and the presenter
	 *     package both do this.
	 *
	 * Indeterminate:
	 *   • A repeating stripe sweeps across the track. Real `value` is
	 *     ignored; meant for "loading" decks where the bar is decorative.
	 */
	import { onMount } from 'svelte';
	import type { ProgressElement } from '../types';
	import { gradientShapeToCss } from '../engine/utils';

	interface Props {
		element: ProgressElement;
		isPresenting?: boolean;
		slideId?: string;
		previousElement?: ProgressElement | null;
	}
	let { element, isPresenting = false, slideId = '', previousElement = null }: Props = $props();

	let animatedValue = $state(0);
	let animationFrame: number | null = null;

	function ease(t: number) { return 1 - Math.pow(1 - t, 3); }

	function animateTo(from: number, to: number, durationMs: number) {
		if (animationFrame) cancelAnimationFrame(animationFrame);
		const start = performance.now();
		function tick() {
			const p = Math.min((performance.now() - start) / Math.max(1, durationMs), 1);
			animatedValue = from + (to - from) * ease(p);
			if (p < 1) animationFrame = requestAnimationFrame(tick);
		}
		animationFrame = requestAnimationFrame(tick);
	}

	function runAnimation() {
		const target = element.value;
		const duration = element.animationDuration ?? 1200;
		if (!isPresenting) { animatedValue = target; return; }
		const from = previousElement ? previousElement.value : 0;
		animateTo(from, target, duration);
	}

	onMount(() => {
		runAnimation();
		return () => { if (animationFrame) cancelAnimationFrame(animationFrame); };
	});

	$effect(() => {
		if (isPresenting && slideId) runAnimation();
	});

	// Static-time preview (editor): re-sync when the user types a new value.
	$effect(() => {
		if (!isPresenting) animatedValue = element.value;
	});

	const max = $derived(Math.max(0.0001, element.max ?? 100));
	const ratio = $derived(Math.max(0, Math.min(1, animatedValue / max)));

	// Fill resolution — gradient overrides solid color, same as text/chart.
	const fillCss = $derived(
		element.fillGradient ? gradientShapeToCss(element.fillGradient) : element.fillColor
	);

	function format(value: number, fmt: NonNullable<ProgressElement['label']>['format'], customText?: string): string {
		const v = Math.round(value);
		const pct = Math.round((value / max) * 100);
		if (fmt === 'percent') return `${pct}%`;
		if (fmt === 'value') return `${v}`;
		if (fmt === 'value-of-max') return `${v}/${Math.round(max)}`;
		if (fmt === 'custom' && customText) {
			return customText.replace('{value}', String(v)).replace('{max}', String(Math.round(max))).replace('{percent}', String(pct));
		}
		return `${pct}%`;
	}

	const labelText = $derived(element.label && element.label.position !== 'none'
		? format(animatedValue, element.label.format, element.label.customText)
		: ''
	);

	// Normalize legacy `above`/`below`/`center` to the new 4-side scheme.
	// `above` / `below` pre-dated explicit horizontal positions; map both
	// to top/bottom regardless of bar shape so existing decks stay sane.
	type Side = 'top' | 'bottom' | 'left' | 'right' | 'inside' | 'none';
	const labelSide = $derived<Side>((() => {
		const p = element.label?.position;
		if (!p || p === 'none') return 'none';
		if (p === 'above') return 'top';
		if (p === 'below') return 'bottom';
		if (p === 'center') return 'inside';
		return p as Side;
	})());

	// Layout direction for the wrapper: column when label is top/bottom,
	// row when left/right. We never use *-reverse — the markup just
	// renders the label before or after the bar based on `labelGoesFirst`,
	// and the natural flex flow places it correctly.
	const wrapperDir = $derived<'column' | 'row'>(
		labelSide === 'left' || labelSide === 'right' ? 'row' : 'column'
	);
	// Label first = top or left side; label after the bar = bottom or right.
	const labelGoesFirst = $derived(labelSide === 'top' || labelSide === 'left');

	// Bar geometry (horizontal + vertical share the same logic).
	const isBar = $derived(element.shape !== 'ring');
	const isHorizontal = $derived(element.shape === 'bar-horizontal');
	const trackBorderRadius = $derived(`${(element.roundness ?? 50) * 0.01 * element.thickness}px`);

	// Direction → which edge the fill anchors to.
	const fillAnchor = $derived(() => {
		switch (element.direction) {
			case 'rtl': return 'right';
			case 'ttb': return 'top';
			case 'btt': return 'bottom';
			default: return 'left'; // ltr is default
		}
	});

	// Ring geometry. The viewBox is 100x100 so the stroke-width is in
	// viewBox units, NOT pixels — `thickness` (which feels pixel-y to the
	// user) gets clamped to a fraction of the viewBox so the stroke always
	// fits without being clipped at the element's bounds. Radius shrinks
	// to match: outer edge = radius + stroke/2 must stay ≤ 50.
	const ringStrokeWidth = $derived(Math.min(40, Math.max(2, element.thickness ?? 8)));
	const ringRadius = $derived((100 - ringStrokeWidth) / 2);
	const ringCirc = $derived(2 * Math.PI * ringRadius);
	const ringDashOffset = $derived(ringCirc * (1 - ratio));
	const ringRotation = $derived(element.direction === 'ccw' ? 'rotate(-90deg) scaleX(-1)' : 'rotate(-90deg)');

	// Indeterminate-mode stripe params. Stripes render via a CSS mask
	// over the fill (color OR gradient) so the gradient picker works
	// uniformly.
	//
	// Cycle math: a `repeating-linear-gradient(θdeg, ...)` has its color
	// transition along the θ direction with period = 2*stripeWidth. To
	// shift the mask seamlessly along the bar's flow axis (X for
	// horizontal, Y for vertical), we project that period onto the axis:
	//   - shift along X = period / |sin(θ_rad)|
	//   - shift along Y = period / |cos(θ_rad)|
	// Without this projection the mask "snaps" at loop boundaries because
	// the cycle length doesn't match the visible stripe period along the
	// animated axis.
	const stripeAngle = $derived(element.stripeAngle ?? (isHorizontal ? 45 : -45));
	const stripeWidth = $derived(Math.max(2, element.stripeWidth ?? 14));
	const stripeSpeed = $derived(Math.max(100, element.stripeSpeed ?? 1200));
	const stripePeriod = $derived(stripeWidth * 2);
	// Soft-edged stripe mask: each stripe ramps in for the first 20% of
	// its width, stays solid for 60%, then ramps out for the last 20%.
	// Hard-edged cuts make subpixel rounding at the animation loop
	// boundary visible as a tiny "doubled" stripe; gradient ramps fold
	// the residual into the soft edge so the seam disappears visually.
	const stripeMaskImage = $derived.by(() => {
		const fade = Math.max(2, stripeWidth * 0.2);
		const solidEnd = stripeWidth - fade;
		return `repeating-linear-gradient(${stripeAngle}deg, transparent 0, black ${fade}px, black ${solidEnd}px, transparent ${stripeWidth}px, transparent ${stripePeriod}px)`;
	});
	const stripeCycleAlongAxis = $derived.by(() => {
		const rad = (stripeAngle * Math.PI) / 180;
		const proj = isHorizontal ? Math.abs(Math.sin(rad)) : Math.abs(Math.cos(rad));
		// proj near zero = stripes parallel to the flow axis (no visible
		// motion). Fall back to one nominal cycle so animation still runs
		// instead of dividing by zero.
		return proj > 0.05 ? stripePeriod / proj : stripePeriod;
	});

	// SVG strokes can't use a CSS gradient — they need a <linearGradient>
	// element with id-referenced `stop-color` ops. Precompute the stop list
	// once so the template doesn't need {@const} inside <defs>.
	const ringGradientStops = $derived.by(() => {
		const g = element.fillGradient;
		if (!g) return [];
		if (g.stops && g.stops.length > 0) return g.stops;
		return g.colors.map((c, i, a) => ({
			color: c,
			position: a.length === 1 ? 0 : (i / (a.length - 1)) * 100
		}));
	});
</script>

<div
	class="progress-element"
	class:bar={isBar}
	class:horizontal={isHorizontal}
	class:vertical={!isHorizontal && isBar}
	class:ring={element.shape === 'ring'}
	class:indeterminate={element.indeterminate}
	style:opacity={element.opacity ?? 1}
	style:flex-direction={wrapperDir}
>
	{#if labelGoesFirst && (labelSide === 'top' || labelSide === 'bottom' || labelSide === 'left' || labelSide === 'right') && labelText}
		<div class="label outside" style:font-size="{element.label?.fontSize ?? 14}px" style:font-weight={element.label?.fontWeight ?? 600} style:color={element.label?.color ?? '#ffffff'}>{labelText}</div>
	{/if}

	{#if isBar}
		<div
			class="bar-track"
			style:height={isHorizontal ? `${element.thickness}px` : '100%'}
			style:width={isHorizontal ? '100%' : `${element.thickness}px`}
			style:background={element.showTrack ? element.trackColor : 'transparent'}
			style:border-radius={trackBorderRadius}
		>
			{#if !element.indeterminate}
				<div
					class="bar-fill"
					style:background={fillCss}
					style:border-radius={trackBorderRadius}
					style:width={isHorizontal ? `${ratio * 100}%` : '100%'}
					style:height={isHorizontal ? '100%' : `${ratio * 100}%`}
					style:left={isHorizontal && fillAnchor() === 'left' ? '0' : 'auto'}
					style:right={isHorizontal && fillAnchor() === 'right' ? '0' : 'auto'}
					style:top={!isHorizontal && fillAnchor() === 'top' ? '0' : 'auto'}
					style:bottom={!isHorizontal && fillAnchor() === 'bottom' ? '0' : 'auto'}
				>
					{#if labelSide === 'inside' && labelText && isHorizontal}
						<span class="label inside" style:font-size="{element.label?.fontSize ?? 12}px" style:font-weight={element.label?.fontWeight ?? 600} style:color={element.label?.color ?? '#ffffff'}>{labelText}</span>
					{/if}
				</div>
			{:else}
				<!-- Indeterminate: a stripe TAPE wider than the bar by one
				     cycle slides via transform. The mask rasterizes once
				     onto the tape; the tape itself translates. Because the
				     mask is repeating with the same cycle, the visible
				     window at translateX=0 and translateX=-cycle shows
				     identical content — the loop is seamless regardless
				     of angle, with no rasterization artifact at the
				     boundary (transform is hardware-composited; the prior
				     mask-position approach re-rasterized per frame and hit
				     subpixel rounding on irrational cycles). -->
				<div class="bar-stripe-clip" style:border-radius={trackBorderRadius}>
					<div
						class="bar-stripe-tape"
						class:vertical={!isHorizontal}
						style:background={fillCss}
						style:--stripe-mask={stripeMaskImage}
						style:--stripe-cycle="{stripeCycleAlongAxis}px"
						style:--stripe-speed="{stripeSpeed}ms"
						style:--stripe-direction={element.direction === 'rtl' || element.direction === 'ttb' ? 'reverse' : 'normal'}
					></div>
				</div>
			{/if}
			<!-- Vertical bars: render the inside label on the TRACK (not the fill).
			     The fill height varies with value and would cut off labels at low
			     percentages. Centering on the track keeps the label readable
			     regardless of progress. -->
			{#if !isHorizontal && labelSide === 'inside' && labelText}
				<span class="label inside-vertical" style:font-size="{element.label?.fontSize ?? 12}px" style:font-weight={element.label?.fontWeight ?? 600} style:color={element.label?.color ?? '#ffffff'}>{labelText}</span>
			{/if}
		</div>
	{:else}
		<svg class="ring-svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
			<g style:transform={ringRotation} style:transform-origin="center">
				{#if element.showTrack}
					<circle cx="50" cy="50" r={ringRadius} fill="none" stroke={element.trackColor} stroke-width={ringStrokeWidth} />
				{/if}
				{#if !element.indeterminate}
					<circle
						cx="50"
						cy="50"
						r={ringRadius}
						fill="none"
						stroke={element.fillGradient ? 'url(#progress-grad-{element.id})' : element.fillColor}
						stroke-width={ringStrokeWidth}
						stroke-dasharray={ringCirc}
						stroke-dashoffset={ringDashOffset}
						stroke-linecap={element.roundness && element.roundness > 0 ? 'round' : 'butt'}
					/>
				{:else}
					<!-- Indeterminate: a short dash that sweeps around. -->
					<circle
						cx="50"
						cy="50"
						r={ringRadius}
						fill="none"
						stroke={element.fillColor}
						stroke-width={ringStrokeWidth}
						stroke-dasharray="{ringCirc * 0.25} {ringCirc * 0.75}"
						stroke-linecap="round"
						class="ring-spin"
					/>
				{/if}
			</g>
			{#if element.fillGradient}
				<defs>
					<!-- SVG strokes need an SVG paint, not a CSS gradient. Linear-only
					     for ring strokes (radial doesn't make sense on a stroked path).
					     `ringGradientStops` precomputed in the script — {@const} can't
					     live directly inside <defs>, has to be inside an {#if}/{#each}. -->
					<linearGradient id="progress-grad-{element.id}" gradientTransform="rotate({element.fillGradient.angle ?? 90})">
						{#each ringGradientStops as s}<stop offset="{s.position}%" stop-color={s.color} />{/each}
					</linearGradient>
				</defs>
			{/if}
		</svg>
		{#if labelSide === 'inside' && labelText}
			<div class="label center" style:font-size="{element.label?.fontSize ?? 18}px" style:font-weight={element.label?.fontWeight ?? 700} style:color={element.label?.color ?? '#ffffff'}>{labelText}</div>
		{/if}
	{/if}

	{#if !labelGoesFirst && (labelSide === 'top' || labelSide === 'bottom' || labelSide === 'left' || labelSide === 'right') && labelText}
		<div class="label outside" style:font-size="{element.label?.fontSize ?? 14}px" style:font-weight={element.label?.fontWeight ?? 600} style:color={element.label?.color ?? '#ffffff'}>{labelText}</div>
	{/if}
</div>

<style>
	.progress-element {
		width: 100%;
		height: 100%;
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		gap: 4px;
		position: relative;
		box-sizing: border-box;
	}
	/* .progress-element flex-direction is set inline based on label
	   position so the label sits on whichever side the user picked. */
	.bar-track, .ring-svg { flex-shrink: 0; }
	.progress-element.ring .ring-svg {
		/* Keep the ring a square that fits the larger of the two flex axes. */
		width: 100%;
		height: 100%;
		min-height: 0;
		min-width: 0;
	}

	.bar-track {
		position: relative;
		overflow: hidden;
	}
	.bar-fill {
		position: absolute;
		display: flex;
		align-items: center;
		justify-content: center;
		transition: width 0s, height 0s; /* JS-driven; CSS transition would fight */
	}
	.bar-stripe-clip {
		position: absolute;
		inset: 0;
		overflow: hidden;
	}
	.bar-stripe-tape {
		position: absolute;
		top: 0;
		left: 0;
		/* Tape is one stripe-cycle wider than the track in the flow
		   direction so the slide ends with the visible window
		   showing content one full cycle to the right of the start
		   — which, for a repeating mask, is identical pixels. */
		width: calc(100% + var(--stripe-cycle));
		height: 100%;
		mask-image: var(--stripe-mask);
		-webkit-mask-image: var(--stripe-mask);
		mask-repeat: no-repeat;
		-webkit-mask-repeat: no-repeat;
		animation: progress-stripe-x var(--stripe-speed) linear infinite;
		animation-direction: var(--stripe-direction);
		will-change: transform;
	}
	.bar-stripe-tape.vertical {
		width: 100%;
		height: calc(100% + var(--stripe-cycle));
		animation-name: progress-stripe-y;
	}
	@keyframes progress-stripe-x {
		from { transform: translateX(0); }
		to   { transform: translateX(calc(-1 * var(--stripe-cycle))); }
	}
	@keyframes progress-stripe-y {
		from { transform: translateY(0); }
		to   { transform: translateY(calc(-1 * var(--stripe-cycle))); }
	}

	.ring-svg {
		width: 100%;
		height: 100%;
	}
	.ring-spin {
		animation: progress-ring-spin 1.4s linear infinite;
		transform-origin: 50% 50%;
	}
	@keyframes progress-ring-spin {
		to { transform: rotate(360deg); }
	}

	.label {
		text-align: center;
		white-space: nowrap;
		pointer-events: none;
	}
	.label.center {
		position: absolute;
		top: 50%; left: 50%;
		transform: translate(-50%, -50%);
	}
	.label.inside {
		padding: 0 8px;
		flex-shrink: 0;
	}
	.label.inside-vertical {
		position: absolute;
		left: 50%;
		top: 50%;
		transform: translate(-50%, -50%);
		text-orientation: mixed;
		writing-mode: horizontal-tb;
	}
</style>
