<!--
  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, Compartment } 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.
		 *  Reactive after mount: held in a compartment reconfigured by the
		 *  `$effect` below, so a role that resolves after mount (warm SPA
		 *  nav) flips the editor writable without a remount. */
		readOnly?: boolean;
	} = $props();

	let host: HTMLDivElement | null = null;
	let view: EditorView | null = null;
	// Holds the readOnly/editable pair so it can be reconfigured live when the
	// `readOnly` prop changes (see the $effect below).
	const readOnlyCompartment = new Compartment();

	function readOnlyExtensions(ro: boolean) {
		// readOnly blocks the underlying transactions; editable suppresses the
		// caret + selection visuals so the user can see it's not editable.
		return [EditorState.readOnly.of(ro), EditorView.editable.of(!ro)];
	}
	// 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,
			// Reconfigurable readOnly/editable — see the $effect below.
			readOnlyCompartment.of(readOnlyExtensions(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;
	});

	// Keep the editor's read-only state in sync with the `readOnly` prop after
	// mount. On a warm SPA navigation the parent's role/`canWrite` can resolve
	// AFTER this component mounts; without this the editor would stay in its
	// initial (often read-only) state even though Save is enabled.
	$effect(() => {
		if (!view) return;
		view.dispatch({
			effects: readOnlyCompartment.reconfigure(readOnlyExtensions(readOnly))
		});
	});

	// 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>
