<!--
  CodeMirror-backed text editor for the dashboard.

  Replaces our plain <textarea> with line numbers, syntax highlighting,
  bracket matching, search/replace (Ctrl+F), and language-aware
  autocomplete. Two-way bound via `value`; the parent treats it the
  same as a textarea.

  Languages: `rhai` (custom mode, ./rhai-mode.ts) and `json`
  (@codemirror/lang-json).
-->
<script lang="ts">
	import { onMount, onDestroy } from 'svelte';
	import { basicSetup } from 'codemirror';
	import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
	import { EditorState } from '@codemirror/state';
	import { indentWithTab } from '@codemirror/commands';
	import { json as jsonLang } from '@codemirror/lang-json';
	import { rhai as rhaiLang } from './rhai-mode';
	import { dashboardSyntaxHighlighting, dashboardTheme } from './editor-theme';

	type Language = 'rhai' | 'json';

	let {
		value = $bindable(''),
		language = 'rhai' as Language,
		placeholder = '',
		minHeight = '12rem',
		readOnly = false
	}: {
		value?: string;
		language?: Language;
		placeholder?: string;
		minHeight?: string;
		/** When true the editor renders without a cursor and rejects
		 *  keystrokes. Parent-driven `value` changes still apply via
		 *  the dispatch path below — this only blocks user edits.
		 *  Not reactive after mount; re-mount via `{#key}` if needed. */
		readOnly?: boolean;
	} = $props();

	let host: HTMLDivElement | null = null;
	let view: EditorView | null = null;
	// Guard against the update-listener firing while we're pushing an
	// external value into the editor — without this, parent-driven
	// `value` changes would echo back through the listener and create a
	// dispatch loop in Svelte 5 reactivity.
	let pushingFromOutside = false;

	function buildExtensions(lang: Language) {
		const extensions = [
			basicSetup,
			lang === 'json' ? jsonLang() : rhaiLang(),
			keymap.of([indentWithTab]),
			dashboardSyntaxHighlighting,
			dashboardTheme,
			// readOnly + editable together: readOnly blocks the
			// underlying transactions, editable suppresses the caret
			// + selection visuals so the user can see it's not
			// editable.
			EditorState.readOnly.of(readOnly),
			EditorView.editable.of(!readOnly),
			EditorView.updateListener.of((update) => {
				if (update.docChanged && !pushingFromOutside) {
					value = update.state.doc.toString();
				}
			})
		];
		if (placeholder) extensions.push(cmPlaceholder(placeholder));
		return extensions;
	}

	onMount(() => {
		if (!host) return;
		view = new EditorView({
			state: EditorState.create({
				doc: value,
				extensions: buildExtensions(language)
			}),
			parent: host
		});
	});

	onDestroy(() => {
		view?.destroy();
		view = null;
	});

	// Push parent-driven `value` updates back into the editor (e.g.
	// when the script is reloaded after Save, or "Format JSON" rewrites
	// the body). We only dispatch when the document genuinely differs
	// from the current `value`.
	$effect(() => {
		if (!view) return;
		const current = view.state.doc.toString();
		if (current !== value) {
			pushingFromOutside = true;
			view.dispatch({
				changes: { from: 0, to: current.length, insert: value }
			});
			pushingFromOutside = false;
		}
	});
</script>

<div bind:this={host} class="cm-host" style:min-height={minHeight}></div>

<style>
	.cm-host :global(.cm-editor) {
		height: 100%;
		border-radius: 0.375rem;
		border: 1px solid #334155;
		overflow: hidden;
	}
	.cm-host :global(.cm-scroller) {
		font-family:
			ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
	}
</style>
