<script lang="ts">
	import { tick } from "svelte";
	import { _ } from "svelte-i18n";
	import { Client } from "@gradio/client";

	import type { LoadingStatus, LoadingStatusCollection } from "./stores";

	import type { ComponentMeta, Dependency, LayoutNode } from "./types";
	import type { UpdateTransaction } from "./init";
	import { setupi18n } from "./i18n";
	import { ApiDocs, ApiRecorder } from "./api_docs/";
	import type { ThemeMode, Payload } from "./types";
	import { Toast } from "@gradio/statustracker";
	import type { ToastMessage } from "@gradio/statustracker";
	import type { ShareData } from "@gradio/utils";
	import MountComponents from "./MountComponents.svelte";

	import logo from "./images/logo.svg";
	import api_logo from "./api_docs/img/api-logo.svg";
	import { create_components, AsyncFunction } from "./init";
	import type {
		LogMessage,
		RenderMessage,
		StatusMessage
	} from "@gradio/client";

	setupi18n();

	export let root: string;
	export let components: ComponentMeta[];
	export let layout: LayoutNode;
	export let dependencies: Dependency[];
	export let title = "Gradio";
	export let target: HTMLElement;
	export let autoscroll: boolean;
	export let show_api = true;
	export let show_footer = true;
	export let control_page_title = false;
	export let app_mode: boolean;
	export let theme_mode: ThemeMode;
	export let app: Awaited<ReturnType<typeof Client.connect>>;
	export let space_id: string | null;
	export let version: string;
	export let js: string | null;
	export let fill_height = false;
	export let ready: boolean;
	export let username: string | null;

	const {
		layout: _layout,
		targets,
		update_value,
		get_data,
		loading_status,
		scheduled_updates,
		create_layout,
		rerender_layout
	} = create_components();

	$: create_layout({
		components,
		layout,
		dependencies,
		root,
		app,
		options: {
			fill_height
		}
	});

	$: {
		ready = !!$_layout;
	}

	let params = new URLSearchParams(window.location.search);
	let api_docs_visible = params.get("view") === "api" && show_api;
	let api_recorder_visible = params.get("view") === "api-recorder" && show_api;
	function set_api_docs_visible(visible: boolean): void {
		api_recorder_visible = false;
		api_docs_visible = visible;
		let params = new URLSearchParams(window.location.search);
		if (visible) {
			params.set("view", "api");
		} else {
			params.delete("view");
		}
		history.replaceState(null, "", "?" + params.toString());
	}
	let api_calls: Payload[] = [];

	export let render_complete = false;
	async function handle_update(data: any, fn_index: number): Promise<void> {
		const outputs = dependencies.find((dep) => dep.id == fn_index)!.outputs;

		const meta_updates = data?.map((value: any, i: number) => {
			return {
				id: outputs[i],
				prop: "value_is_output",
				value: true
			};
		});

		update_value(meta_updates);

		await tick();

		const updates: UpdateTransaction[] = [];

		data?.forEach((value: any, i: number) => {
			if (
				typeof value === "object" &&
				value !== null &&
				value.__type__ === "update"
			) {
				for (const [update_key, update_value] of Object.entries(value)) {
					if (update_key === "__type__") {
						continue;
					} else {
						updates.push({
							id: outputs[i],
							prop: update_key,
							value: update_value
						});
					}
				}
			} else {
				updates.push({
					id: outputs[i],
					prop: "value",
					value
				});
			}
		});
		update_value(updates);

		await tick();
	}

	let submit_map: Map<number, ReturnType<typeof app.submit>> = new Map();

	let messages: (ToastMessage & { fn_index: number })[] = [];
	function new_message(
		message: string,
		fn_index: number,
		type: ToastMessage["type"],
		duration: number | null = 10,
		visible = true
	): ToastMessage & { fn_index: number } {
		return {
			message,
			fn_index,
			type,
			id: ++_error_id,
			duration,
			visible
		};
	}

	export function add_new_message(
		message: string,
		type: ToastMessage["type"]
	): void {
		messages = [new_message(message, -1, type), ...messages];
	}

	let _error_id = -1;

	let user_left_page = false;
	document.addEventListener("visibilitychange", function () {
		if (document.visibilityState === "hidden") {
			user_left_page = true;
		}
	});

	const MESSAGE_QUOTE_RE = /^'([^]+)'$/;

	const DUPLICATE_MESSAGE = $_("blocks.long_requests_queue");
	const MOBILE_QUEUE_WARNING = $_("blocks.connection_can_break");
	const MOBILE_RECONNECT_MESSAGE = $_("blocks.lost_connection");
	const SHOW_DUPLICATE_MESSAGE_ON_ETA = 15;
	const SHOW_MOBILE_QUEUE_WARNING_ON_ETA = 10;
	const is_mobile_device =
		/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
			navigator.userAgent
		);
	let showed_duplicate_message = false;
	let showed_mobile_warning = false;

	// as state updates are not synchronous, we need to ensure updates are flushed before triggering any requests
	function wait_then_trigger_api_call(
		dep_index: number,
		trigger_id: number | null = null,
		event_data: unknown = null
	): void {
		let _unsub = (): void => {};
		function unsub(): void {
			_unsub();
		}
		if ($scheduled_updates) {
			_unsub = scheduled_updates.subscribe((updating) => {
				if (!updating) {
					trigger_api_call(dep_index, trigger_id, event_data);
					unsub();
				}
			});
		} else {
			trigger_api_call(dep_index, trigger_id, event_data);
		}
	}

	async function trigger_api_call(
		dep_index: number,
		trigger_id: number | null = null,
		event_data: unknown = null
	): Promise<void> {
		let dep = dependencies.find((dep) => dep.id === dep_index)!;

		const current_status = loading_status.get_status_for_fn(dep_index);
		messages = messages.filter(({ fn_index }) => fn_index !== dep_index);
		if (current_status === "pending" || current_status === "generating") {
			dep.pending_request = true;
		}

		let payload: Payload = {
			fn_index: dep_index,
			data: await Promise.all(dep.inputs.map((id) => get_data(id))),
			event_data: dep.collects_event_data ? event_data : null,
			trigger_id: trigger_id
		};

		if (dep.frontend_fn) {
			dep
				.frontend_fn(
					payload.data.concat(
						await Promise.all(dep.outputs.map((id) => get_data(id)))
					)
				)
				.then((v: unknown[]) => {
					if (dep.backend_fn) {
						payload.data = v;
						trigger_prediction(dep, payload);
					} else {
						handle_update(v, dep_index);
					}
				});
		} else if (dep.types.cancel && dep.cancels) {
			await Promise.all(
				dep.cancels.map(async (fn_index) => {
					const submission = submit_map.get(fn_index);
					submission?.cancel();
					return submission;
				})
			);
		} else {
			if (dep.backend_fn) {
				trigger_prediction(dep, payload);
			}
		}

		function trigger_prediction(dep: Dependency, payload: Payload): void {
			if (dep.trigger_mode === "once") {
				if (!dep.pending_request) make_prediction(payload);
			} else if (dep.trigger_mode === "multiple") {
				make_prediction(payload);
			} else if (dep.trigger_mode === "always_last") {
				if (!dep.pending_request) {
					make_prediction(payload);
				} else {
					dep.final_event = payload;
				}
			}
		}

		async function make_prediction(payload: Payload): Promise<void> {
			if (api_recorder_visible) {
				api_calls = [...api_calls, JSON.parse(JSON.stringify(payload))];
			}

			let submission: ReturnType<typeof app.submit>;
			try {
				submission = app.submit(
					payload.fn_index,
					payload.data as unknown[],
					payload.event_data,
					payload.trigger_id
				);
			} catch (e) {
				const fn_index = 0; // Mock value for fn_index
				messages = [new_message(String(e), fn_index, "error"), ...messages];
				loading_status.update({
					status: "error",
					fn_index,
					eta: 0,
					queue: false,
					queue_position: null
				});
				set_status($loading_status);
				return;
			}

			submit_map.set(dep_index, submission);

			for await (const message of submission) {
				if (message.type === "data") {
					handle_data(message);
				} else if (message.type === "render") {
					handle_render(message);
				} else if (message.type === "status") {
					handle_status_update(message);
				} else if (message.type === "log") {
					handle_log(message);
				}
			}

			function handle_data(message: Payload): void {
				const { data, fn_index } = message;
				if (dep.pending_request && dep.final_event) {
					dep.pending_request = false;
					make_prediction(dep.final_event);
				}
				dep.pending_request = false;
				handle_update(data, fn_index);
				set_status($loading_status);
			}

			function handle_render(message: RenderMessage): void {
				const { data } = message;
				let _components: ComponentMeta[] = data.components;
				let render_layout: LayoutNode = data.layout;
				let _dependencies: Dependency[] = data.dependencies;
				let render_id = data.render_id;

				let deps_to_remove: number[] = [];
				dependencies.forEach((dep, i) => {
					if (dep.rendered_in === render_id) {
						deps_to_remove.push(i);
					}
				});
				deps_to_remove.reverse().forEach((i) => {
					dependencies.splice(i, 1);
				});
				_dependencies.forEach((dep) => {
					dependencies.push(dep);
				});

				rerender_layout({
					components: _components,
					layout: render_layout,
					root: root,
					dependencies: dependencies,
					render_id: render_id
				});
			}

			function handle_log(msg: LogMessage): void {
				const { log, fn_index, level, duration, visible } = msg;
				messages = [
					new_message(log, fn_index, level, duration, visible),
					...messages
				];
			}

			function handle_status_update(message: StatusMessage): void {
				const { fn_index, ...status } = message;
				//@ts-ignore
				loading_status.update({
					...status,
					status: status.stage,
					progress: status.progress_data,
					fn_index
				});
				set_status($loading_status);
				if (
					!showed_duplicate_message &&
					space_id !== null &&
					status.position !== undefined &&
					status.position >= 2 &&
					status.eta !== undefined &&
					status.eta > SHOW_DUPLICATE_MESSAGE_ON_ETA
				) {
					showed_duplicate_message = true;
					messages = [
						new_message(DUPLICATE_MESSAGE, fn_index, "warning"),
						...messages
					];
				}
				if (
					!showed_mobile_warning &&
					is_mobile_device &&
					status.eta !== undefined &&
					status.eta > SHOW_MOBILE_QUEUE_WARNING_ON_ETA
				) {
					showed_mobile_warning = true;
					messages = [
						new_message(MOBILE_QUEUE_WARNING, fn_index, "warning"),
						...messages
					];
				}

				if (status.stage === "complete") {
					status.changed_state_ids?.forEach((id) => {
						dependencies
							.filter((dep) => dep.targets.some(([_id, _]) => _id === id))
							.forEach((dep) => {
								wait_then_trigger_api_call(dep.id, payload.trigger_id);
							});
					});
					dependencies.forEach(async (dep) => {
						if (dep.trigger_after === fn_index) {
							wait_then_trigger_api_call(dep.id, payload.trigger_id);
						}
					});

					// submission.destroy();
				}
				if (status.broken && is_mobile_device && user_left_page) {
					window.setTimeout(() => {
						messages = [
							new_message(MOBILE_RECONNECT_MESSAGE, fn_index, "error"),
							...messages
						];
					}, 0);
					wait_then_trigger_api_call(dep.id, payload.trigger_id, event_data);
					user_left_page = false;
				} else if (status.stage === "error") {
					if (status.message) {
						const _message = status.message.replace(
							MESSAGE_QUOTE_RE,
							(_, b) => b
						);
						messages = [
							new_message(
								_message,
								fn_index,
								"error",
								status.duration,
								status.visible
							),
							...messages
						];
					}
					dependencies.map(async (dep) => {
						if (
							dep.trigger_after === fn_index &&
							!dep.trigger_only_on_success
						) {
							wait_then_trigger_api_call(dep.id, payload.trigger_id);
						}
					});
				}
			}
		}
	}

	function trigger_share(title: string | undefined, description: string): void {
		if (space_id === null) {
			return;
		}
		const discussion_url = new URL(
			`https://huggingface.co./spaces/${space_id}/discussions/new`
		);
		if (title !== undefined && title.length > 0) {
			discussion_url.searchParams.set("title", title);
		}
		discussion_url.searchParams.set("description", description);
		window.open(discussion_url.toString(), "_blank");
	}

	function handle_error_close(e: Event & { detail: number }): void {
		const _id = e.detail;
		messages = messages.filter((m) => m.id !== _id);
	}

	const is_external_url = (link: string | null): boolean =>
		!!(link && new URL(link, location.href).origin !== location.origin);

	async function handle_mount(): Promise<void> {
		if (js) {
			let blocks_frontend_fn = new AsyncFunction(
				`let result = await (${js})();
					return (!Array.isArray(result)) ? [result] : result;`
			);
			await blocks_frontend_fn();
		}

		await tick();

		var a = target.getElementsByTagName("a");

		for (var i = 0; i < a.length; i++) {
			const _target = a[i].getAttribute("target");
			const _link = a[i].getAttribute("href");

			// only target anchor tags with external links
			if (is_external_url(_link) && _target !== "_blank")
				a[i].setAttribute("target", "_blank");
		}

		// handle load triggers
		dependencies.forEach((dep) => {
			if (dep.targets.some((dep) => dep[1] === "load")) {
				wait_then_trigger_api_call(dep.id);
			}
		});

		if (render_complete) return;

		target.addEventListener("prop_change", (e: Event) => {
			if (!isCustomEvent(e)) throw new Error("not a custom event");
			const { id, prop, value } = e.detail;
			update_value([{ id, prop, value }]);
		});
		target.addEventListener("gradio", (e: Event) => {
			if (!isCustomEvent(e)) throw new Error("not a custom event");

			const { id, event, data } = e.detail;

			if (event === "share") {
				const { title, description } = data as ShareData;
				trigger_share(title, description);
			} else if (event === "error" || event === "warning") {
				messages = [new_message(data, -1, event), ...messages];
			} else if (event == "clear_status") {
				update_status(id, "complete", data);
			} else {
				const deps = $targets[id]?.[event];

				deps?.forEach((dep_id) => {
					requestAnimationFrame(() => {
						wait_then_trigger_api_call(dep_id, id, data);
					});
				});
			}
		});

		render_complete = true;
	}

	$: set_status($loading_status);

	function update_status(
		id: number,
		status: "error" | "complete" | "pending",
		data: LoadingStatus
	): void {
		data.status = status;
		update_value([
			{
				id,
				prop: "loading_status",
				value: data
			}
		]);
	}

	function set_status(statuses: LoadingStatusCollection): void {
		let updates: {
			id: number;
			prop: string;
			value: LoadingStatus;
		}[] = [];
		Object.entries(statuses).forEach(([id, loading_status]) => {
			let dependency = dependencies.find(
				(dep) => dep.id == loading_status.fn_index
			);
			if (dependency === undefined) {
				return;
			}
			loading_status.scroll_to_output = dependency.scroll_to_output;
			loading_status.show_progress = dependency.show_progress;
			updates.push({
				id: parseInt(id),
				prop: "loading_status",
				value: loading_status
			});
		});

		const inputs_to_update = loading_status.get_inputs_to_update();
		const additional_updates = Array.from(inputs_to_update).map(
			([id, pending_status]) => {
				return {
					id,
					prop: "pending",
					value: pending_status === "pending"
				};
			}
		);

		update_value([...updates, ...additional_updates]);
	}

	function isCustomEvent(event: Event): event is CustomEvent {
		return "detail" in event;
	}
</script>

<svelte:head>
	{#if control_page_title}
		<title>{title}</title>
	{/if}
</svelte:head>

<div class="wrap" style:min-height={app_mode ? "100%" : "auto"}>
	<div class="contain" style:flex-grow={app_mode ? "1" : "auto"}>
		{#if $_layout && app.config}
			<MountComponents
				rootNode={$_layout}
				{root}
				{target}
				{theme_mode}
				on:mount={handle_mount}
				{version}
				{autoscroll}
				max_file_size={app.config.max_file_size}
				client={app}
			/>
		{/if}
	</div>

	{#if show_footer}
		<footer>
			{#if show_api}
				<button
					on:click={() => {
						set_api_docs_visible(!api_docs_visible);
					}}
					class="show-api"
				>
					{$_("errors.use_via_api")}
					<img src={api_logo} alt={$_("common.logo")} />
				</button>
				<div>·</div>
			{/if}
			<a
				href="https://gradio.app"
				class="built-with"
				target="_blank"
				rel="noreferrer"
			>
				{$_("common.built_with_gradio")}
				<img src={logo} alt={$_("common.logo")} />
			</a>
		</footer>
	{/if}
</div>

{#if api_recorder_visible}
	<!-- TODO: fix -->
	<!-- svelte-ignore a11y-click-events-have-key-events-->
	<!-- svelte-ignore a11y-no-static-element-interactions-->
	<div
		id="api-recorder-container"
		on:click={() => {
			set_api_docs_visible(true);
			api_recorder_visible = false;
		}}
	>
		<ApiRecorder {api_calls} {dependencies} />
	</div>
{/if}

{#if api_docs_visible && $_layout}
	<div class="api-docs">
		<!-- TODO: fix -->
		<!-- svelte-ignore a11y-click-events-have-key-events-->
		<!-- svelte-ignore a11y-no-static-element-interactions-->
		<div
			class="backdrop"
			on:click={() => {
				set_api_docs_visible(false);
			}}
		/>
		<div class="api-docs-wrap">
			<ApiDocs
				root_node={$_layout}
				on:close={(event) => {
					set_api_docs_visible(false);
					api_calls = [];
					api_recorder_visible = event.detail.api_recorder_visible;
				}}
				{dependencies}
				{root}
				{app}
				{space_id}
				{api_calls}
				{username}
			/>
		</div>
	</div>
{/if}

{#if messages}
	<Toast {messages} on:close={handle_error_close} />
{/if}

<style>
	.wrap {
		display: flex;
		flex-grow: 1;
		flex-direction: column;
		width: var(--size-full);
		font-weight: var(--body-text-weight);
		font-size: var(--body-text-size);
	}

	.contain {
		display: flex;
		flex-direction: column;
	}

	footer {
		display: flex;
		justify-content: center;
		margin-top: var(--size-4);
		color: var(--body-text-color-subdued);
	}

	footer > * + * {
		margin-left: var(--size-2);
	}

	.show-api {
		display: flex;
		align-items: center;
	}
	.show-api:hover {
		color: var(--body-text-color);
	}

	.show-api img {
		margin-right: var(--size-1);
		margin-left: var(--size-2);
		width: var(--size-3);
	}

	.built-with {
		display: flex;
		align-items: center;
	}

	.built-with:hover {
		color: var(--body-text-color);
	}

	.built-with img {
		margin-right: var(--size-1);
		margin-left: var(--size-1);
		margin-bottom: 1px;
		width: var(--size-4);
	}

	.api-docs {
		display: flex;
		position: fixed;
		top: 0;
		right: 0;
		z-index: var(--layer-top);
		background: rgba(0, 0, 0, 0.5);
		width: var(--size-screen);
		height: var(--size-screen-h);
	}

	.backdrop {
		flex: 1 1 0%;
		-webkit-backdrop-filter: blur(4px);
		backdrop-filter: blur(4px);
	}

	.api-docs-wrap {
		box-shadow: var(--shadow-drop-lg);
		background: var(--background-fill-primary);
		overflow-x: hidden;
		overflow-y: auto;
	}

	@media (--screen-md) {
		.api-docs-wrap {
			border-top-left-radius: var(--radius-lg);
			border-bottom-left-radius: var(--radius-lg);
			width: 950px;
		}
	}

	@media (--screen-xxl) {
		.api-docs-wrap {
			width: 1150px;
		}
	}

	#api-recorder-container {
		position: fixed;
		left: 10px;
		bottom: 10px;
		z-index: 1000;
	}
</style>
