<script lang="ts">
	import type { Gradio, SelectData } from "@gradio/utils";
	import { BlockTitle } from "@gradio/atoms";
	import { Block } from "@gradio/atoms";
	import { StatusTracker } from "@gradio/statustracker";
	import type { LoadingStatus } from "@gradio/statustracker";
	import { onMount } from "svelte";

	import type { TopLevelSpec as Spec } from "vega-lite";
	import type { View } from "vega";
	import { LineChart as LabelIcon } from "@gradio/icons";
	import { Empty } from "@gradio/atoms";

	interface PlotData {
		columns: string[];
		data: [string | number][];
		datatypes: Record<string, "quantitative" | "temporal" | "nominal">;
		mark: "line" | "point" | "bar";
	}
	export let value: PlotData | null;
	export let x: string;
	export let y: string;
	export let color: string | null = null;
	export let root: string;
	$: unique_colors =
		color && value && value.datatypes[color] === "nominal"
			? Array.from(new Set(_data.map((d) => d[color])))
			: [];

	export let title: string | null = null;
	export let x_title: string | null = null;
	export let y_title: string | null = null;
	export let color_title: string | null = null;
	export let x_bin: string | number | null = null;
	export let y_aggregate:
		| "sum"
		| "mean"
		| "median"
		| "min"
		| "max"
		| undefined = undefined;
	export let color_map: Record<string, string> | null = null;
	export let x_lim: [number, number] | null = null;
	export let y_lim: [number, number] | null = null;
	export let x_label_angle: number | null = null;
	export let y_label_angle: number | null = null;
	export let x_axis_labels_visible = true;
	export let caption: string | null = null;
	export let sort: "x" | "y" | "-x" | "-y" | string[] | null = null;
	function reformat_sort(
		_sort: typeof sort
	):
		| string
		| "ascending"
		| "descending"
		| { field: string; order: "ascending" | "descending" }
		| string[]
		| undefined {
		if (_sort === "x") {
			return "ascending";
		} else if (_sort === "-x") {
			return "descending";
		} else if (_sort === "y") {
			return { field: y, order: "ascending" };
		} else if (_sort === "-y") {
			return { field: y, order: "descending" };
		} else if (_sort === null) {
			return undefined;
		} else if (Array.isArray(_sort)) {
			return _sort;
		}
	}
	$: _sort = reformat_sort(sort);
	export let _selectable = false;
	let _data: {
		[x: string]: string | number;
	}[];
	export let gradio: Gradio<{
		select: SelectData;
		double_click: undefined;
		clear_status: LoadingStatus;
	}>;

	$: x_temporal = value && value.datatypes[x] === "temporal";
	$: _x_lim = x_lim && x_temporal ? [x_lim[0] * 1000, x_lim[1] * 1000] : x_lim;
	let _x_bin: number | undefined;
	let mouse_down_on_chart = false;
	const SUFFIX_DURATION: Record<string, number> = {
		s: 1,
		m: 60,
		h: 60 * 60,
		d: 24 * 60 * 60
	};
	$: _x_bin = x_bin
		? typeof x_bin === "string"
			? 1000 *
				parseInt(x_bin.substring(0, x_bin.length - 1)) *
				SUFFIX_DURATION[x_bin[x_bin.length - 1]]
			: x_bin
		: undefined;
	let _y_aggregate: typeof y_aggregate;
	let aggregating: boolean;
	$: {
		if (value) {
			if (value.mark === "point") {
				aggregating = _x_bin !== undefined;
				_y_aggregate = y_aggregate || aggregating ? "sum" : undefined;
			} else {
				aggregating = _x_bin !== undefined || value.datatypes[x] === "nominal";
				_y_aggregate = y_aggregate ? y_aggregate : "sum";
			}
		}
	}
	function reformat_data(data: PlotData): {
		[x: string]: string | number;
	}[] {
		let x_index = data.columns.indexOf(x);
		let y_index = data.columns.indexOf(y);
		let color_index = color ? data.columns.indexOf(color) : null;
		return data.data.map((row) => {
			const obj = {
				[x]: row[x_index],
				[y]: row[y_index]
			};
			if (color && color_index !== null) {
				obj[color] = row[color_index];
			}
			return obj;
		});
	}
	$: _data = value ? reformat_data(value) : [];

	const is_browser = typeof window !== "undefined";
	let chart_element: HTMLDivElement;
	$: computed_style = chart_element
		? window.getComputedStyle(chart_element)
		: null;
	let view: View;
	let mounted = false;
	let old_width: number;
	let resizeObserver: ResizeObserver;

	let vegaEmbed: typeof import("vega-embed").default;
	async function load_chart(): Promise<void> {
		if (view) {
			view.finalize();
		}
		if (!value || !chart_element) return;
		old_width = chart_element.offsetWidth;
		const spec = create_vega_lite_spec();
		if (!spec) return;
		resizeObserver = new ResizeObserver((el) => {
			if (!el[0].target || !(el[0].target instanceof HTMLElement)) return;
			if (
				old_width === 0 &&
				chart_element.offsetWidth !== 0 &&
				value.datatypes[x] === "nominal"
			) {
				// a bug where when a nominal chart is first loaded, the width is 0, it doesn't resize
				load_chart();
			} else {
				view.signal("width", el[0].target.offsetWidth).run();
			}
		});

		if (!vegaEmbed) {
			vegaEmbed = (await import("vega-embed")).default;
		}
		vegaEmbed(chart_element, spec, { actions: false }).then(function (result) {
			view = result.view;

			resizeObserver.observe(chart_element);
			var debounceTimeout: NodeJS.Timeout;
			view.addEventListener("dblclick", () => {
				gradio.dispatch("double_click");
			});
			// prevent double-clicks from highlighting text
			chart_element.addEventListener(
				"mousedown",
				function (e) {
					if (e.detail > 1) {
						e.preventDefault();
					}
				},
				false
			);
			if (_selectable) {
				view.addSignalListener("brush", function (_, value) {
					if (Object.keys(value).length === 0) return;
					clearTimeout(debounceTimeout);
					let range: [number, number] = value[Object.keys(value)[0]];
					if (x_temporal) {
						range = [range[0] / 1000, range[1] / 1000];
					}
					let callback = (): void => {
						gradio.dispatch("select", {
							value: range,
							index: range,
							selected: true
						});
					};
					if (mouse_down_on_chart) {
						release_callback = callback;
					} else {
						debounceTimeout = setTimeout(function () {
							gradio.dispatch("select", {
								value: range,
								index: range,
								selected: true
							});
						}, 250);
					}
				});
			}
		});
	}

	let release_callback: (() => void) | null = null;
	onMount(() => {
		mounted = true;
		chart_element.addEventListener("mousedown", () => {
			mouse_down_on_chart = true;
		});
		chart_element.addEventListener("mouseup", () => {
			mouse_down_on_chart = false;
			if (release_callback) {
				release_callback();
				release_callback = null;
			}
		});

		return () => {
			mounted = false;
			if (view) {
				view.finalize();
			}
			if (resizeObserver) {
				resizeObserver.disconnect();
			}
		};
	});

	$: title,
		x_title,
		y_title,
		color_title,
		x,
		y,
		color,
		x_bin,
		_y_aggregate,
		color_map,
		x_lim,
		y_lim,
		caption,
		sort,
		value,
		mounted,
		chart_element,
		computed_style && requestAnimationFrame(load_chart);

	function create_vega_lite_spec(): Spec | null {
		if (!value || !computed_style) return null;
		let accent_color = computed_style.getPropertyValue("--color-accent");
		let body_text_color = computed_style.getPropertyValue("--body-text-color");
		let borderColorPrimary = computed_style.getPropertyValue(
			"--border-color-primary"
		);
		let font_family = computed_style.fontFamily;
		let title_weight = computed_style.getPropertyValue(
			"--block-title-text-weight"
		) as
			| "bold"
			| "normal"
			| 100
			| 200
			| 300
			| 400
			| 500
			| 600
			| 700
			| 800
			| 900;
		const font_to_px_val = (font: string): number => {
			return font.endsWith("px") ? parseFloat(font.slice(0, -2)) : 12;
		};
		let text_size_md = font_to_px_val(
			computed_style.getPropertyValue("--text-md")
		);
		let text_size_sm = font_to_px_val(
			computed_style.getPropertyValue("--text-sm")
		);

		/* eslint-disable complexity */
		return {
			$schema: "https://vega.github.io/schema/vega-lite/v5.17.0.json",
			background: "transparent",
			config: {
				autosize: { type: "fit", contains: "padding" },
				axis: {
					labelFont: font_family,
					labelColor: body_text_color,
					titleFont: font_family,
					titleColor: body_text_color,
					titlePadding: 8,
					tickColor: borderColorPrimary,
					labelFontSize: text_size_sm,
					gridColor: borderColorPrimary,
					titleFontWeight: "normal",
					titleFontSize: text_size_sm,
					labelFontWeight: "normal",
					domain: false,
					labelAngle: 0
				},
				legend: {
					labelColor: body_text_color,
					labelFont: font_family,
					titleColor: body_text_color,
					titleFont: font_family,
					titleFontWeight: "normal",
					titleFontSize: text_size_sm,
					labelFontWeight: "normal",
					offset: 2
				},
				title: {
					color: body_text_color,
					font: font_family,
					fontSize: text_size_md,
					fontWeight: title_weight,
					anchor: "middle"
				},
				view: { stroke: borderColorPrimary },
				mark: {
					stroke: value.mark !== "bar" ? accent_color : undefined,
					fill: value.mark === "bar" ? accent_color : undefined,
					cursor: "crosshair"
				}
			},
			data: { name: "data" },
			datasets: {
				data: _data
			},
			layer: ["plot", ...(value.mark === "line" ? ["hover"] : [])].map(
				(mode) => {
					return {
						encoding: {
							size:
								value.mark === "line"
									? mode == "plot"
										? {
												condition: {
													empty: false,
													param: "hoverPlot",
													value: 3
												},
												value: 2
											}
										: {
												condition: { empty: false, param: "hover", value: 100 },
												value: 0
											}
									: undefined,
							opacity:
								mode === "plot"
									? undefined
									: {
											condition: { empty: false, param: "hover", value: 1 },
											value: 0
										},
							x: {
								axis: {
									...(x_label_angle !== null && { labelAngle: x_label_angle }),
									labels: x_axis_labels_visible,
									ticks: x_axis_labels_visible
								},
								field: x,
								title: x_title || x,
								type: value.datatypes[x],
								scale: _x_lim ? { domain: _x_lim } : undefined,
								bin: _x_bin ? { step: _x_bin } : undefined,
								sort: _sort
							},
							y: {
								axis: y_label_angle ? { labelAngle: y_label_angle } : {},
								field: y,
								title: y_title || y,
								type: value.datatypes[y],
								scale: y_lim ? { domain: y_lim } : undefined,
								aggregate: aggregating ? _y_aggregate : undefined
							},
							color: color
								? {
										field: color,
										legend: { orient: "bottom", title: color_title },
										scale:
											value.datatypes[color] === "nominal"
												? {
														domain: unique_colors,
														range: color_map
															? unique_colors.map((c) => color_map[c])
															: undefined
													}
												: {
														range: [
															100, 200, 300, 400, 500, 600, 700, 800, 900
														].map((n) =>
															computed_style.getPropertyValue("--primary-" + n)
														),
														interpolate: "hsl"
													},
										type: value.datatypes[color]
									}
								: undefined,
							tooltip: [
								{
									field: y,
									type: value.datatypes[y],
									aggregate: aggregating ? _y_aggregate : undefined,
									title: y_title || y
								},
								{
									field: x,
									type: value.datatypes[x],
									title: x_title || x,
									format: x_temporal ? "%Y-%m-%d %H:%M:%S" : undefined,
									bin: _x_bin ? { step: _x_bin } : undefined
								},
								...(color
									? [
											{
												field: color,
												type: value.datatypes[color]
											}
										]
									: [])
							]
						},
						strokeDash: {},
						mark: { clip: true, type: mode === "hover" ? "point" : value.mark },
						name: mode
					};
				}
			),
			// @ts-ignore
			params: [
				...(value.mark === "line"
					? [
							{
								name: "hoverPlot",
								select: {
									clear: "mouseout",
									fields: color ? [color] : [],
									nearest: true,
									on: "mouseover",
									type: "point" as "point"
								},
								views: ["hover"]
							},
							{
								name: "hover",
								select: {
									clear: "mouseout",
									nearest: true,
									on: "mouseover",
									type: "point" as "point"
								},
								views: ["hover"]
							}
						]
					: []),
				...(_selectable
					? [
							{
								name: "brush",
								select: {
									encodings: ["x"],
									mark: { fill: "gray", fillOpacity: 0.3, stroke: "none" },
									type: "interval" as "interval"
								},
								views: ["plot"]
							}
						]
					: [])
			],
			width: chart_element.offsetWidth,
			title: title || undefined
		};
		/* eslint-enable complexity */
	}

	export let label = "Textbox";
	export let elem_id = "";
	export let elem_classes: string[] = [];
	export let visible = true;
	export let show_label: boolean;
	export let scale: number | null = null;
	export let min_width: number | undefined = undefined;
	export let loading_status: LoadingStatus | undefined = undefined;
	export let height: number | undefined = undefined;
</script>

<Block
	{visible}
	{elem_id}
	{elem_classes}
	{scale}
	{min_width}
	allow_overflow={false}
	padding={true}
	{height}
>
	{#if loading_status}
		<StatusTracker
			autoscroll={gradio.autoscroll}
			i18n={gradio.i18n}
			{...loading_status}
			on:clear_status={() => gradio.dispatch("clear_status", loading_status)}
		/>
	{/if}
	<BlockTitle {root} {show_label} info={undefined}>{label}</BlockTitle>
	{#if value && is_browser}
		<div bind:this={chart_element}></div>

		{#if caption}
			<p class="caption">{caption}</p>
		{/if}
	{:else}
		<Empty unpadded_box={true}><LabelIcon /></Empty>
	{/if}
</Block>

<style>
	div {
		width: 100%;
	}
	:global(#vg-tooltip-element) {
		font-family: var(--font) !important;
		font-size: var(--text-xs) !important;
		box-shadow: none !important;
		background-color: var(--block-background-fill) !important;
		border: 1px solid var(--border-color-primary) !important;
		color: var(--body-text-color) !important;
	}
	:global(#vg-tooltip-element .key) {
		color: var(--body-text-color-subdued) !important;
	}
	.caption {
		padding: 0 4px;
		margin: 0;
		text-align: center;
	}
</style>