<script lang="ts">
	import {
		format_chat_for_sharing,
		type UndoRetryData,
		is_last_bot_message,
		group_messages,
		load_components,
		get_components_from_messages
	} from "./utils";
	import type { NormalisedMessage } from "../types";
	import { copy } from "@gradio/utils";
	import Message from "./Message.svelte";

	import { dequal } from "dequal/lite";
	import {
		afterUpdate,
		createEventDispatcher,
		type SvelteComponent,
		type ComponentType,
		tick,
		onMount
	} from "svelte";
	import { Image } from "@gradio/image/shared";

	import { Clear, Trash, Community, ScrollDownArrow } from "@gradio/icons";
	import { IconButtonWrapper, IconButton } from "@gradio/atoms";
	import type { SelectData, LikeData } from "@gradio/utils";
	import type { ExampleMessage } from "../types";
	import { MarkdownCode as Markdown } from "@gradio/markdown";
	import type { FileData, Client } from "@gradio/client";
	import type { I18nFormatter } from "js/core/src/gradio_helper";
	import Pending from "./Pending.svelte";
	import { ShareError } from "@gradio/utils";
	import { Gradio } from "@gradio/utils";

	export let value: NormalisedMessage[] | null = [];
	let old_value: NormalisedMessage[] | null = null;

	import CopyAll from "./CopyAll.svelte";

	export let _fetch: typeof fetch;
	export let load_component: Gradio["load_component"];

	let _components: Record<string, ComponentType<SvelteComponent>> = {};

	async function update_components(): Promise<void> {
		_components = await load_components(
			get_components_from_messages(value),
			_components,
			load_component
		);
	}

	$: value, update_components();

	export let latex_delimiters: {
		left: string;
		right: string;
		display: boolean;
	}[];
	export let pending_message = false;
	export let generating = false;
	export let selectable = false;
	export let likeable = false;
	export let show_share_button = false;
	export let show_copy_all_button = false;
	export let rtl = false;
	export let show_copy_button = false;
	export let avatar_images: [FileData | null, FileData | null] = [null, null];
	export let sanitize_html = true;
	export let bubble_full_width = true;
	export let render_markdown = true;
	export let line_breaks = true;
	export let autoscroll = true;
	export let theme_mode: "system" | "light" | "dark";
	export let i18n: I18nFormatter;
	export let layout: "bubble" | "panel" = "bubble";
	export let placeholder: string | null = null;
	export let upload: Client["upload"];
	export let msg_format: "tuples" | "messages" = "tuples";
	export let examples: ExampleMessage[] | null = null;
	export let _retryable = false;
	export let _undoable = false;
	export let like_user_message = false;
	export let root: string;

	let target: HTMLElement | null = null;

	onMount(() => {
		target = document.querySelector("div.gradio-container");
	});

	let div: HTMLDivElement;

	let show_scroll_button = false;

	const dispatch = createEventDispatcher<{
		change: undefined;
		select: SelectData;
		like: LikeData;
		undo: UndoRetryData;
		retry: UndoRetryData;
		clear: undefined;
		share: any;
		error: string;
		example_select: SelectData;
	}>();

	function is_at_bottom(): boolean {
		return div && div.offsetHeight + div.scrollTop > div.scrollHeight - 100;
	}

	function scroll_to_bottom(): void {
		if (!div) return;
		div.scrollTo(0, div.scrollHeight);
		show_scroll_button = false;
	}

	let scroll_after_component_load = false;
	function on_child_component_load(): void {
		if (scroll_after_component_load) {
			scroll_to_bottom();
			scroll_after_component_load = false;
		}
	}

	async function scroll_on_value_update(): Promise<void> {
		if (!autoscroll) return;

		if (is_at_bottom()) {
			// Child components may be loaded asynchronously,
			// so trigger the scroll again after they load.
			scroll_after_component_load = true;

			await tick(); // Wait for the DOM to update so that the scrollHeight is correct
			scroll_to_bottom();
		} else {
			show_scroll_button = true;
		}
	}
	onMount(() => {
		scroll_on_value_update();
	});
	$: if (value || pending_message || _components) {
		scroll_on_value_update();
	}

	onMount(() => {
		function handle_scroll(): void {
			if (is_at_bottom()) {
				show_scroll_button = false;
			} else {
				scroll_after_component_load = false;
			}
		}

		div?.addEventListener("scroll", handle_scroll);
		return () => {
			div?.removeEventListener("scroll", handle_scroll);
		};
	});

	let image_preview_source: string;
	let image_preview_source_alt: string;
	let is_image_preview_open = false;

	afterUpdate(() => {
		if (!div) return;
		div.querySelectorAll("img").forEach((n) => {
			n.addEventListener("click", (e) => {
				const target = e.target as HTMLImageElement;
				if (target) {
					image_preview_source = target.src;
					image_preview_source_alt = target.alt;
					is_image_preview_open = true;
				}
			});
		});
	});

	$: {
		if (!dequal(value, old_value)) {
			old_value = value;
			dispatch("change");
		}
	}

	$: groupedMessages = value && group_messages(value, msg_format);

	function handle_example_select(i: number, example: ExampleMessage): void {
		dispatch("example_select", {
			index: i,
			value: { text: example.text, files: example.files }
		});
	}

	function handle_like(
		i: number,
		message: NormalisedMessage,
		selected: string | null
	): void {
		if (selected === "undo" || selected === "retry") {
			const val_ = value as NormalisedMessage[];
			// iterate through messages until we find the last user message
			// the index of this message is where the user needs to edit the chat history
			let last_index = val_.length - 1;
			while (val_[last_index].role === "assistant") {
				last_index--;
			}
			dispatch(selected, {
				index: val_[last_index].index,
				value: val_[last_index].content
			});
			return;
		}

		if (msg_format === "tuples") {
			dispatch("like", {
				index: message.index,
				value: message.content,
				liked: selected === "like"
			});
		} else {
			if (!groupedMessages) return;

			const message_group = groupedMessages[i];
			const [first, last] = [
				message_group[0],
				message_group[message_group.length - 1]
			];

			dispatch("like", {
				index: [first.index, last.index] as [number, number],
				value: message_group.map((m) => m.content),
				liked: selected === "like"
			});
		}
	}
</script>

{#if value !== null && value.length > 0}
	<IconButtonWrapper>
		{#if show_share_button}
			<IconButton
				Icon={Community}
				on:click={async () => {
					try {
						// @ts-ignore
						const formatted = await format_chat_for_sharing(value);
						dispatch("share", {
							description: formatted
						});
					} catch (e) {
						console.error(e);
						let message = e instanceof ShareError ? e.message : "Share failed.";
						dispatch("error", message);
					}
				}}
			>
				<Community />
			</IconButton>
		{/if}
		<IconButton Icon={Trash} on:click={() => dispatch("clear")} label={"Clear"}
		></IconButton>
		{#if show_copy_all_button}
			<CopyAll {value} />
		{/if}
	</IconButtonWrapper>
{/if}

<div
	class={layout === "bubble" ? "bubble-wrap" : "panel-wrap"}
	bind:this={div}
	role="log"
	aria-label="chatbot conversation"
	aria-live="polite"
>
	{#if value !== null && value.length > 0 && groupedMessages !== null}
		<div class="message-wrap" use:copy>
			{#each groupedMessages as messages, i}
				{@const role = messages[0].role === "user" ? "user" : "bot"}
				{@const avatar_img = avatar_images[role === "user" ? 0 : 1]}
				{@const opposite_avatar_img = avatar_images[role === "user" ? 0 : 1]}
				{#if is_image_preview_open}
					<div class="image-preview">
						<img src={image_preview_source} alt={image_preview_source_alt} />
						<button
							class="image-preview-close-button"
							on:click={() => {
								is_image_preview_open = false;
							}}><Clear /></button
						>
					</div>
				{/if}
				<Message
					{messages}
					{opposite_avatar_img}
					{avatar_img}
					{role}
					{layout}
					{dispatch}
					{i18n}
					{_fetch}
					{line_breaks}
					{theme_mode}
					{target}
					{root}
					{upload}
					{selectable}
					{sanitize_html}
					{bubble_full_width}
					{render_markdown}
					{rtl}
					{i}
					{value}
					{latex_delimiters}
					{_components}
					{generating}
					{msg_format}
					show_like={role === "user" ? likeable && like_user_message : likeable}
					show_retry={_retryable && is_last_bot_message(messages, value)}
					show_undo={_undoable && is_last_bot_message(messages, value)}
					{show_copy_button}
					handle_action={(selected) => handle_like(i, messages[0], selected)}
					{scroll}
				/>
			{/each}
			{#if pending_message}
				<Pending {layout} />
			{/if}
		</div>
	{:else}
		<div class="placeholder-content">
			{#if placeholder !== null}
				<div class="placeholder">
					<Markdown message={placeholder} {latex_delimiters} {root} />
				</div>
			{/if}
			{#if examples !== null}
				<div class="examples">
					{#each examples as example, i}
						<button
							class="example"
							on:click={() => handle_example_select(i, example)}
						>
							{#if example.icon !== undefined}
								<div class="example-icon-container">
									<Image
										class="example-icon"
										src={example.icon.url}
										alt="example-icon"
									/>
								</div>
							{/if}
							{#if example.display_text !== undefined}
								<span class="example-display-text">{example.display_text}</span>
							{:else}
								<span class="example-text">{example.text}</span>
							{/if}
							{#if example.files !== undefined && example.files.length > 1}
								<span class="example-file"
									><em>{example.files.length} Files</em></span
								>
							{:else if example.files !== undefined && example.files[0] !== undefined && example.files[0].mime_type?.includes("image")}
								<div class="example-image-container">
									<Image
										class="example-image"
										src={example.files[0].url}
										alt="example-image"
									/>
								</div>
							{:else if example.files !== undefined && example.files[0] !== undefined}
								<span class="example-file"
									><em>{example.files[0].orig_name}</em></span
								>
							{/if}
						</button>
					{/each}
				</div>
			{/if}
		</div>
	{/if}
</div>

{#if show_scroll_button}
	<div class="scroll-down-button-container">
		<IconButton
			Icon={ScrollDownArrow}
			label="Scroll down"
			size="large"
			on:click={scroll_to_bottom}
		/>
	</div>
{/if}

<style>
	.placeholder-content {
		display: flex;
		flex-direction: column;
		height: 100%;
	}

	.placeholder {
		align-items: center;
		display: flex;
		justify-content: center;
		height: 100%;
		flex-grow: 1;
	}

	.examples :global(img) {
		pointer-events: none;
	}

	.examples {
		margin: auto;
		padding: var(--spacing-xxl);
		display: grid;
		grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
		gap: var(--spacing-xxl);
		max-width: calc(min(4 * 200px + 5 * var(--spacing-xxl), 100%));
	}

	.example {
		display: flex;
		flex-direction: column;
		align-items: center;
		padding: var(--spacing-xl);
		border: 0.05px solid var(--border-color-primary);
		border-radius: var(--radius-xl);
		background-color: var(--background-fill-secondary);
		cursor: pointer;
		transition: var(--button-transition);
		max-width: var(--size-56);
		width: 100%;
	}

	.example:hover {
		background-color: var(--color-accent-soft);
		border-color: var(--border-color-accent);
	}

	.example-icon-container {
		display: flex;
		align-self: flex-start;
		margin-left: var(--spacing-md);
		width: var(--size-6);
		height: var(--size-6);
	}

	.example-display-text,
	.example-text,
	.example-file {
		font-size: var(--text-md);
		width: 100%;
		text-align: center;
		overflow: hidden;
		text-overflow: ellipsis;
	}

	.example-display-text,
	.example-file {
		margin-top: var(--spacing-md);
	}

	.example-image-container {
		flex-grow: 1;
		display: flex;
		justify-content: center;
		align-items: center;
		margin-top: var(--spacing-xl);
	}

	.example-image-container :global(img) {
		max-height: 100%;
		max-width: 100%;
		height: var(--size-32);
		width: 100%;
		object-fit: cover;
		border-radius: var(--radius-xl);
	}

	.panel-wrap {
		width: 100%;
		overflow-y: auto;
	}

	.bubble-wrap {
		width: 100%;
		overflow-y: auto;
		height: 100%;
		padding-top: var(--spacing-xxl);
	}

	@media (prefers-color-scheme: dark) {
		.bubble-wrap {
			background: var(--background-fill-secondary);
		}
	}

	.message-wrap {
		display: flex;
		flex-direction: column;
		justify-content: space-between;
		margin-bottom: var(--spacing-xxl);
	}

	.message-wrap :global(.prose.chatbot.md) {
		opacity: 0.8;
		overflow-wrap: break-word;
	}

	.message-wrap :global(.message-row .md img) {
		border-radius: var(--radius-xl);
		margin: var(--size-2);
		width: 400px;
		max-width: 30vw;
		max-height: 30vw;
	}

	/* link styles */
	.message-wrap :global(.message a) {
		color: var(--color-text-link);
		text-decoration: underline;
	}

	/* table styles */
	.message-wrap :global(.bot table),
	.message-wrap :global(.bot tr),
	.message-wrap :global(.bot td),
	.message-wrap :global(.bot th) {
		border: 1px solid var(--border-color-primary);
	}

	.message-wrap :global(.user table),
	.message-wrap :global(.user tr),
	.message-wrap :global(.user td),
	.message-wrap :global(.user th) {
		border: 1px solid var(--border-color-accent);
	}

	/* KaTeX */
	.message-wrap :global(span.katex) {
		font-size: var(--text-lg);
		direction: ltr;
	}

	.message-wrap :global(span.katex-display) {
		margin-top: 0;
	}

	.message-wrap :global(pre) {
		position: relative;
	}

	.message-wrap :global(.grid-wrap) {
		max-height: 80% !important;
		max-width: 600px;
		object-fit: contain;
	}

	.message-wrap > div :global(p:not(:first-child)) {
		margin-top: var(--spacing-xxl);
	}

	.message-wrap {
		display: flex;
		flex-direction: column;
		justify-content: space-between;
		margin-bottom: var(--spacing-xxl);
	}

	.panel-wrap :global(.message-row:first-child) {
		padding-top: calc(var(--spacing-xxl) * 2);
	}

	.scroll-down-button-container {
		position: absolute;
		bottom: 10px;
		left: 50%;
		transform: translateX(-50%);
		z-index: var(--layer-top);
	}
	.scroll-down-button-container :global(button) {
		border-radius: 50%;
		box-shadow: var(--shadow-drop);
		transition:
			box-shadow 0.2s ease-in-out,
			transform 0.2s ease-in-out;
	}
	.scroll-down-button-container :global(button:hover) {
		box-shadow:
			var(--shadow-drop),
			0 2px 2px rgba(0, 0, 0, 0.05);
		transform: translateY(-2px);
	}
</style>
