<script lang="ts">
	import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
	import { BooleanString, ChatFormInputRichTokenKind, ColorMode } from '$lib/enums';
	import { deviceStore } from '$lib/stores';
	import type { ChatFormInputRichToken } from '$lib/types';
	import type { SourceHistoryEntry } from '$lib/utils';
	import {
		badgeAwareWordJump,
		buildFragment,
		domMatchesTokens,
		highlightCode,
		isIMEComposing,
		isOffsetInCodeBlock,
		leadingBadgeEdgeOffset,
		rangeToTextOffset,
		serializeContent,
		SourceHistory,
		stripBlockBoundaryLineBreaks,
		syncCodeBlockHatches,
		textOffsetToRange,
		tokenizeContent
	} from '$lib/utils';
	import githubLightCss from 'highlight.js/styles/github.css?inline';
	import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
	import { mode } from 'mode-watcher';
	import { onDestroy, onMount, untrack } from 'svelte';

	interface Props {
		class?: string;
		disabled?: boolean;
		onInput?: () => void;
		onKeydown?: (event: KeyboardEvent) => void;
		onPaste?: (event: ClipboardEvent) => void;
		placeholder?: string;
		value?: string;
	}

	let {
		class: className = '',
		disabled = false,
		onInput,
		onKeydown,
		onPaste,
		placeholder = 'Ask anything...',
		value = $bindable('')
	}: Props = $props();

	let rootElement: HTMLDivElement | undefined = $state();
	let lastEmittedValue = '';
	let isComposing = $state(false);

	// Undo/redo in source space: the imperative token rebuilds destroy the
	// browser's native undo stack.
	const history = new SourceHistory();

	// Browsers disagree on what an empty rich chat form input contains (`<br>`,
	// `<div><br></div>`, or nothing), so emptiness is decided by the
	// serialized source, not the DOM shape.
	function syncEmptyState(serialized?: string) {
		if (!rootElement) return;

		const source = serialized ?? serializeContent(rootElement);

		rootElement.dataset.empty = source.length === 0 ? BooleanString.TRUE : BooleanString.FALSE;
	}

	function renderTokens(tokens: ChatFormInputRichToken[]) {
		if (!rootElement) return;

		const caret = rangeToTextOffset(rootElement, safeRange());

		// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
		rootElement.replaceChildren(buildFragment(tokens));

		syncCodeBlockHatches(rootElement);
		highlightCodeBlocks(rootElement);

		restoreCaret(caret);
		resizeHeight();
		syncEmptyState();
	}

	// Last highlighted source segment per block element - typing inside
	// a block re-highlights only when the segment actually changed.
	const highlightedSegments = new WeakMap<HTMLElement, string>();

	const CODE_BLOCK_OPEN_RE = /^```([^\n`]*)\n/;

	/**
	 * Apply syntax highlighting to a code block element's CONTENT. The
	 * fence lines stay plain text, and the blank padding that
	 * `highlightCode` trims is re-added as plain text, so the element's
	 * textContent stays byte-exact with the source segment. Replaces
	 * the element's children - callers restore the caret afterwards.
	 * Returns false when nothing changed.
	 */
	function highlightCodeBlockElement(el: HTMLElement): boolean {
		const segment = el.textContent ?? '';

		if (highlightedSegments.get(el) === segment) return false;

		const open = CODE_BLOCK_OPEN_RE.exec(segment);

		if (!open) return false;

		const prefix = open[0];
		const language = open[1].trim().split(/\s+/)[0] ?? '';
		const content = segment.slice(prefix.length, -3);
		const leading = content.match(CODE_BLOCK.TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
		const trailing = content.match(CODE_BLOCK.TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
		const core = content.slice(leading.length, content.length - trailing.length);
		// autoDetect off: re-guessing the language on every keystroke
		// costs ~38ms a call and flickers while typing
		const html = core ? highlightCode(core, language || 'text', false) : '';
		const tpl = document.createElement('template');

		tpl.innerHTML = html;

		el.replaceChildren(
			document.createTextNode(prefix + leading),
			tpl.content.cloneNode(true),
			document.createTextNode(trailing + '```')
		);
		highlightedSegments.set(el, segment);

		return true;
	}

	function highlightCodeBlocks(root: HTMLElement) {
		for (const el of root.querySelectorAll<HTMLElement>(
			`code[${CODE_TOKEN_ATTR}="${ChatFormInputRichTokenKind.CODE_BLOCK}"]`
		)) {
			highlightCodeBlockElement(el);
		}
	}

	/**
	 * Re-highlight the code block the caret sits in after an edit.
	 * Skipped when the block's segment is unchanged since its last
	 * highlight, so edits outside blocks cost nothing.
	 */
	function rehighlightCaretCodeBlock() {
		if (!rootElement) return;

		const range = safeRange();

		if (!range) return;

		let node: Node | null = range.startContainer;

		if (node === rootElement) {
			node = rootElement.childNodes[range.startOffset - 1] ?? null;
		}

		while (node && node !== rootElement) {
			if (
				node instanceof HTMLElement &&
				node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
			) {
				const caret = rangeToTextOffset(rootElement, range);

				if (highlightCodeBlockElement(node)) {
					restoreCaret(caret);
				}

				return;
			}

			node = node.parentNode;
		}
	}

	/**
	 * Is the caret inside a fenced code block region? Source-level
	 * (not DOM-level) so the still-OPEN fence counts too: while the
	 * user is typing a block, no closing ``` exists yet and the
	 * buffer is plain text with no block element to find. Root-level
	 * caret positions right at a closed block's edge (escape
	 * hatches, element boundaries restored by `textOffsetToRange`)
	 * resolve past the closing fence, so they count as OUTSIDE.
	 */
	function caretInCodeBlock(): boolean {
		if (!rootElement) return false;

		return isOffsetInCodeBlock(
			serializeContent(rootElement),
			rangeToTextOffset(rootElement, safeRange())
		);
	}

	/**
	 * hljs theme for the highlighted code blocks. Mirrors
	 * SyntaxHighlightedCode.svelte: one shared style element
	 * (deduped via the data attribute) swapped on mode change.
	 */
	function loadHighlightTheme(isDark: boolean) {
		document
			.querySelectorAll(`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`)
			.forEach((s) => s.remove());

		const style = document.createElement('style');

		style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
		style.textContent = isDark ? githubDarkCss : githubLightCss;

		document.head.appendChild(style);
	}

	$effect(() => {
		loadHighlightTheme(mode.current === ColorMode.DARK);
	});

	function safeRange(): Range | null {
		if (!rootElement) return null;

		const selection = window.getSelection();

		if (!selection || selection.rangeCount === 0) return null;

		const range = selection.getRangeAt(0);

		if (!rootElement.contains(range.startContainer) || !rootElement.contains(range.endContainer)) {
			return null;
		}

		return range;
	}

	function restoreCaret(offset: number, extend = false) {
		if (!rootElement) return;

		const target = textOffsetToRange(rootElement, offset);
		const selection = window.getSelection();

		if (!selection) return;

		if (extend && selection.anchorNode) {
			selection.setBaseAndExtent(
				selection.anchorNode,
				selection.anchorOffset,
				target.startContainer,
				target.startOffset
			);

			return;
		}

		selection.removeAllRanges();
		selection.addRange(target);
	}

	function resizeHeight() {
		if (!rootElement) return;

		rootElement.style.height = 'auto';
		rootElement.style.height = `${rootElement.scrollHeight}px`;
	}

	function recordHistory(newGroup: boolean) {
		if (!rootElement) return;

		history.push(
			{ caret: rangeToTextOffset(rootElement, safeRange()), value: lastEmittedValue },
			Date.now(),
			newGroup
		);
	}

	/**
	 * Re-emit the current markdown source value to the parent, then
	 * reconcile the DOM against the token stream: when a code span
	 * was just completed or broken, the token boundaries no longer
	 * match the element structure and the DOM is rebuilt (caret
	 * preserved through the source-offset mapping).
	 */
	function processInput(inputType?: string) {
		if (isComposing || !rootElement) return;

		syncEmptyState();
		resizeHeight();

		// Shift+Enter right after a code block leaves an all-newline
		// text node (the fence's separator line plus Chromium's
		// artificial end-of-buffer line break). Strip both so the caret
		// lands on the line directly below the block.
		if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
			const caret = rangeToTextOffset(rootElement, safeRange());

			if (stripBlockBoundaryLineBreaks(rootElement)) {
				restoreCaret(caret);
			} else {
				const source = serializeContent(rootElement);

				let end = caret;

				// the caret must end up after the inserted \n; some browsers
				// leave it before (stuck at the end of the old line). A
				// preceding \n means it already sits past the break
				// (Chromium's artificial trailing newline) - leave it.
				if (source[end] === '\n' && source[end - 1] !== '\n') {
					end += 1;
					restoreCaret(end);
				}

				// a line break at the buffer end renders only with a second,
				// artificial trailing \n: a lone trailing \n is collapsed, so
				// the new line is invisible and the next typed character
				// consumes it. Append it when missing - unless the trailing
				// \n doubles as a block's separator line (source ends with
				// \n\n) or sits inside a block element.
				let last = rootElement.lastChild;

				while (last && last.nodeName === 'BR') last = last.previousSibling;

				if (
					end === source.length &&
					source.endsWith('\n') &&
					source[source.length - 2] !== '\n' &&
					last?.nodeType === Node.TEXT_NODE
				) {
					// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
					rootElement.appendChild(document.createTextNode('\n'));
					restoreCaret(source.length);
					resizeHeight();
				}
			}
		}

		syncCodeBlockHatches(rootElement);

		const serialized = serializeContent(rootElement);

		syncEmptyState(serialized);

		if (serialized === lastEmittedValue) return;

		// Plain typing/deletes coalesce per time window; structural edits
		// (paste, newline, cut, autocorrect) start a new undo group.
		recordHistory(inputType !== 'insertText' && !inputType?.startsWith('deleteContent'));

		lastEmittedValue = serialized;
		value = serialized;

		// Rebuild when token boundaries shifted (a code span was just
		// completed or broken) - the browser-owned text nodes cannot
		// restyle themselves across element boundaries.
		const tokens = tokenizeContent(serialized);

		if (!domMatchesTokens(rootElement, tokens)) {
			renderTokens(tokens);

			// The rebuild can re-shape the DOM in a way that changes the
			// serialization (e.g. Chromium merged trailing text into the
			// block element and the rebuild splits it back out, which
			// synthesizes the separator newline) - keep value in sync.
			const reserialized = serializeContent(rootElement);

			if (reserialized !== serialized) {
				lastEmittedValue = reserialized;
				value = reserialized;
			}
		} else {
			rehighlightCaretCodeBlock();
		}

		onInput?.();
	}

	function handleInput(event: Event) {
		processInput((event as InputEvent).inputType);
	}

	function handleCompositionStart() {
		isComposing = true;
	}

	function handleCompositionEnd() {
		isComposing = false;
		processInput();
	}

	/**
	 * Insert a line break at the caret MANUALLY. Native Shift+Enter at
	 * the buffer end varies across browsers (a lone trailing \n that the
	 * renderer collapses, or a <br> that the hatch sync strips), which
	 * can leave the caret stuck on the old line; splitting the text node
	 * ourselves keeps the DOM shape - and the caret - deterministic.
	 * `processInput` then appends the artificial trailing \n when the
	 * break lands at the buffer end.
	 */
	function insertLineBreak() {
		if (!rootElement) return;

		const range = safeRange();

		if (!range) return;

		if (!range.collapsed) {
			range.deleteContents();
		}

		const container = range.startContainer;
		const offset = range.startOffset;
		const nl = document.createTextNode('\n');

		// a break at the very end of a code block exits the block (the
		// new line belongs below it, not inside)
		let exitBlock: HTMLElement | null = null;

		if (container.nodeType === Node.TEXT_NODE) {
			let node: Node | null = container.parentNode;

			while (node && node !== rootElement) {
				if (
					node instanceof HTMLElement &&
					node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
				) {
					const tail = document.createRange();

					tail.setStart(container, offset);
					tail.setEnd(node, node.childNodes.length);

					if (tail.toString().length === 0) exitBlock = node;

					break;
				}

				node = node.parentNode;
			}
		}

		if (exitBlock) {
			exitBlock.after(nl);
		} else if (container.nodeType === Node.TEXT_NODE) {
			const text = container as Text;

			if (offset === 0) {
				text.before(nl);
			} else if (offset === text.length) {
				text.after(nl);
			} else {
				text.splitText(offset).before(nl);
			}
		} else {
			container.insertBefore(nl, container.childNodes[offset] ?? null);
		}

		const selection = window.getSelection();
		const after = document.createRange();

		after.setStartAfter(nl);
		after.collapse(true);
		selection?.removeAllRanges();
		selection?.addRange(after);

		processInput('insertLineBreak');
	}

	/**
	 * Arrow escape to the line BEFORE a leading code block. Native
	 * caret movement has no position above a buffer-starting block,
	 * so a transient `<br>` hatch is created on demand: it gives the
	 * caret a visible line, is consumed by the first character typed
	 * on it, and is removed again when the caret leaves (see
	 * handleSelectionChange). Returns true when the caret was moved.
	 */
	function moveCaretBeforeLeadingCodeBlock(key: string, extend: boolean): boolean {
		if (!rootElement) return false;

		// a hatch already exists - native movement handles it
		if (rootElement.firstChild?.nodeName === 'BR') return false;

		const first = rootElement.firstChild;

		if (
			!(first instanceof HTMLElement) ||
			first.getAttribute(CODE_TOKEN_ATTR) !== ChatFormInputRichTokenKind.CODE_BLOCK
		)
			return false;

		const range = safeRange();

		if (!range || !range.collapsed) return false;

		// the caret must sit inside the block: on its very first
		// character for ArrowLeft, anywhere on its first line for
		// ArrowUp
		if (!first.contains(range.startContainer)) return false;

		const caret = rangeToTextOffset(rootElement, range);

		if (key === 'ArrowLeft') {
			if (caret !== 0) return false;
		} else {
			const firstLineEnd = (first.textContent ?? '').indexOf('\n');

			if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
		}

		// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
		rootElement.prepend(document.createElement('br'));
		restoreCaret(0, extend);

		return true;
	}

	/**
	 * Remove the transient leading hatch once the caret leaves it.
	 * The hatch only exists to give the caret a line above a leading
	 * code block; with the caret anywhere else the empty line would
	 * just be visual noise. Typing on the hatch line consumes it via
	 * the stale-hatch removal in `syncCodeBlockHatches` instead (the
	 * new text node takes its place before the block).
	 */
	function handleSelectionChange() {
		if (!rootElement) return;

		const first = rootElement.firstChild;

		if (first?.nodeName !== 'BR') return;

		const second = first.nextSibling;

		if (
			!(second instanceof HTMLElement) ||
			second.getAttribute(CODE_TOKEN_ATTR) !== ChatFormInputRichTokenKind.CODE_BLOCK
		)
			return;

		const range = safeRange();
		const onHatch =
			range !== null && range.startContainer === rootElement && range.startOffset === 0;

		if (!onHatch) {
			first.remove();
		}
	}

	/**
	 * Undo/redo is replayed from source snapshots (the token rebuilds
	 * destroy the native undo stack). Arrow keys around badges are
	 * repaired locally: a badge is a non-editable island, so plain
	 * ArrowLeft after a leading badge has no native previous position
	 * and word jumps overshoot it by a word.
	 *
	 * Plain Enter inside a fenced code block (closed, or still open
	 * while being typed) acts as Shift+Enter and adds a line instead of
	 * submitting. ArrowLeft/ArrowUp at the edge of a leading code block
	 * create the transient before-block hatch.
	 */
	function handleKeydown(event: KeyboardEvent) {
		const mod = event.ctrlKey || event.metaKey;

		if (mod && !event.altKey && !isComposing && rootElement) {
			const key = event.key.toLowerCase();
			const isUndo = key === 'z' && !event.shiftKey;
			const isRedo = key === 'y' || (key === 'z' && event.shiftKey);

			if (isUndo || isRedo) {
				event.preventDefault();
				const current = {
					caret: rangeToTextOffset(rootElement, safeRange()),
					value: lastEmittedValue
				};
				const entry = isUndo ? history.undo(current) : history.redo(current);

				if (entry) applyHistoryEntry(entry);

				return;
			}
		}

		if (
			event.key === 'Enter' &&
			event.shiftKey &&
			!event.ctrlKey &&
			!event.metaKey &&
			!event.altKey &&
			!isIMEComposing(event) &&
			!disabled &&
			!caretInCodeBlock() &&
			safeRange()
		) {
			// Own the break outside code blocks: native end-of-buffer
			// behavior varies across browsers and can leave the caret
			// stuck on the old line (see insertLineBreak).
			event.preventDefault();
			insertLineBreak();

			return;
		}

		if (
			event.key === 'Enter' &&
			!event.shiftKey &&
			!event.ctrlKey &&
			!event.metaKey &&
			!event.altKey &&
			!isIMEComposing(event) &&
			caretInCodeBlock()
		) {
			// The native plain-Enter path must never run: it splits the
			// buffer into `<div>` wrappers that `serializeContent` cannot
			// see. `insertLineBreak` reproduces the Shift+Enter DOM (a `\n`
			// text node) and fires `input` synchronously, so the usual
			// re-tokenize/re-highlight follows.
			event.preventDefault();
			document.execCommand('insertLineBreak');

			return;
		}

		if (
			rootElement &&
			(event.key === 'ArrowLeft' || event.key === 'ArrowUp') &&
			!event.altKey &&
			!event.ctrlKey &&
			!event.metaKey
		) {
			if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
				event.preventDefault();

				return;
			}
		}

		if (rootElement && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
			const isWordJump = (event.altKey || event.ctrlKey) && !event.metaKey && !event.shiftKey;
			const isPlainLeft =
				event.key === 'ArrowLeft' && !event.altKey && !event.ctrlKey && !event.metaKey;

			if (isWordJump || isPlainLeft) {
				const source = serializeContent(rootElement);
				const caret = rangeToTextOffset(rootElement, safeRange());
				const target = isWordJump
					? badgeAwareWordJump(source, caret, event.key === 'ArrowRight' ? 'forward' : 'backward')
					: leadingBadgeEdgeOffset(source, caret);

				if (target !== null) {
					event.preventDefault();
					restoreCaret(target, event.shiftKey);

					return;
				}
			}
		}

		onKeydown?.(event);
	}

	// lastEmittedValue is set before `value` so the sync effect treats the
	// change as our own and does not re-render.
	function applyHistoryEntry(entry: SourceHistoryEntry) {
		if (!rootElement) return;

		renderTokens(tokenizeContent(entry.value));
		lastEmittedValue = entry.value;
		value = entry.value;
		onInput?.();
		restoreCaret(entry.caret);
	}

	/**
	 * Plain-text paste. preventDefault + manual insertText keeps the
	 * browser from producing stray `<div>` wrappers mid-paste; insertText
	 * fires `input` synchronously, so `processInput` re-tokenizes the
	 * buffer and rebuilds when the pasted text carries badge or code
	 * tokens.
	 */
	function handlePasteEvent(event: ClipboardEvent) {
		const pasted = event.clipboardData?.getData('text/plain');

		if (pasted && pasted.length > 0) {
			event.preventDefault();

			// Snap a collapsed caret through the offset mapping first: at
			// element-boundary carets (e.g. right before a badge) Chromium's
			// insertText can drop the preceding text node's trailing whitespace.
			const range = safeRange();

			if (rootElement && range && range.collapsed) {
				restoreCaret(rangeToTextOffset(rootElement, range));
			}

			document.execCommand('insertText', false, pasted);
		}
	}

	// The parent's paste handler runs first and preventDefaults when it
	// consumes the event (files, quoted prompts, long text).
	function handlePaste(event: ClipboardEvent) {
		onPaste?.(event);

		if (!event.defaultPrevented) {
			handlePasteEvent(event);
		}
	}

	// The selection as markdown SOURCE (each badge contributes its full
	// `[name](file://...)` link), so copy/cut carry raw markdown and
	// pasting back re-renders the badges. Null for collapsed/outside
	// selections - native clipboard behavior is fine there.
	function selectionSourceSlice(): { text: string; range: Range } | null {
		if (!rootElement) return null;

		const range = safeRange();

		if (!range || range.collapsed) return null;

		const startRange = range.cloneRange();

		startRange.collapse(true);

		const source = serializeContent(rootElement);
		const start = rangeToTextOffset(rootElement, startRange);
		const end = rangeToTextOffset(rootElement, range);

		return { range, text: source.slice(start, end) };
	}

	function handleCopy(event: ClipboardEvent) {
		const slice = selectionSourceSlice();

		if (!slice) return;

		event.clipboardData?.setData('text/plain', slice.text);
		event.preventDefault();
	}

	function handleCut(event: ClipboardEvent) {
		const slice = selectionSourceSlice();

		if (!slice) return;

		event.clipboardData?.setData('text/plain', slice.text);
		event.preventDefault();

		// preventDefault suppresses the native deletion, so remove the
		// selection manually and re-emit.
		slice.range.deleteContents();
		processInput('deleteByCut');
	}

	onMount(() => {
		// untrack: the DOM is managed manually from input events, so the
		// initial render must not subscribe to the value.
		renderTokens(tokenizeContent(untrack(() => value)));
		lastEmittedValue = untrack(() => value ?? '');
		resizeHeight();
		syncEmptyState();
		document.addEventListener('selectionchange', handleSelectionChange);

		if (!deviceStore.isMobile) {
			rootElement?.focus({ preventScroll: true });
		}
	});

	onDestroy(() => {
		document.removeEventListener('selectionchange', handleSelectionChange);
	});

	// External `value` updates. When incoming === lastEmittedValue the
	// change came from our own input, so leave the DOM alone - the
	// browser already owns the right shape.
	$effect(() => {
		const incoming = value ?? '';

		if (incoming === lastEmittedValue) return;

		recordHistory(true); // external edit (mention insert, clear, ...): own undo step
		renderTokens(tokenizeContent(incoming));
		lastEmittedValue = incoming;
	});

	export function getElement() {
		return rootElement;
	}

	export function getCaretOffset(): number {
		if (!rootElement) return 0;

		return rangeToTextOffset(rootElement, safeRange());
	}

	// Focus first: `selection.addRange` requires it on some browsers.
	export function setCaretOffset(offset: number) {
		if (rootElement && rootElement !== document.activeElement) {
			rootElement.focus({ preventScroll: true });
		}

		restoreCaret(offset);
	}

	export function focus() {
		if (deviceStore.isMobile) return;

		rootElement?.focus({ preventScroll: true });
	}

	export function resetHeight() {
		if (rootElement) {
			rootElement.style.height = '';
			resizeHeight();
		}
	}
</script>

<div class="flex-1 {className} mb-0.5">
	<div
		bind:this={rootElement}
		aria-disabled={disabled}
		aria-multiline="true"
		aria-placeholder={placeholder}
		class={[
			'chat-form-input-rich text-md min-h-12 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
			disabled && 'cursor-not-allowed'
		]}
		contenteditable={!disabled}
		data-placeholder={placeholder}
		oncompositionend={handleCompositionEnd}
		oncompositionstart={handleCompositionStart}
		oncopy={handleCopy}
		oncut={handleCut}
		oninput={handleInput}
		onkeydown={handleKeydown}
		onpaste={handlePaste}
		role="textbox"
		style="max-height: var(--max-message-height);"
		tabindex={disabled ? -1 : 0}
	></div>
</div>

<style>
	/* pre-wrap is load-bearing: without it Chromium collapses \n in
	   text nodes and converts them to spaces while typing */
	.chat-form-input-rich {
		white-space: pre-wrap;
	}

	.chat-form-input-rich:global([data-empty='true'])::before {
		content: attr(data-placeholder);
		color: var(--muted-foreground);
		pointer-events: none;
	}

	/* Inline code - mirrors markdown-content.css */
	.chat-form-input-rich :global(code[data-code-token='code_inline']) {
		background: var(--muted);
		color: var(--muted-foreground);
		padding: 0.125rem 0.375rem;
		border-radius: 0.375rem;
		font-size: 0.875rem;
	}

	/* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */
	.chat-form-input-rich :global(code[data-code-token='code_block']) {
		display: block;
		margin: 0.25rem 0;
		padding: 0.75rem 1rem;
		border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
		border-radius: 0.75rem;
		background: var(--code-background);
		color: var(--code-foreground);
		font-size: 0.875rem;
		line-height: 1.3;
	}
</style>
