import { h, Fragment } from 'preact';
import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
import SignalPicker, { SignalOption } from './SignalPicker';
import { VersionTree, DiffViewer } from './VersionHistory';
import CueEditor from './CueEditor';
import { useAuth } from './lib/auth';
import { ScopeFilter, ScopePicker, filterByScope, normalizeScope, type Bucket } from './lib/scope';

// ── Types (mirror internal/confmgr) ──────────────────────────────────────────

type ParamType = 'float64' | 'int64' | 'bool' | 'string' | 'enum' | 'float64[]';

interface Parameter {
  key: string;
  label?: string;
  group?: string;
  subgroup?: string;
  ds: string;
  signal: string;
  type: ParamType;
  default?: any;
  mandatory?: boolean;
  min?: number;
  max?: number;
  enumValues?: string[];
  unit?: string;
  description?: string;
}

interface ConfigSet {
  id: string;
  name: string;
  description?: string;
  version: number;
  tag?: string;
  owner?: string;
  scope?: string;
  groups?: string[];
  parameters: Parameter[];
}

interface ConfigInstance {
  id: string;
  name: string;
  setId: string;
  setVersion?: number;
  version: number;
  tag?: string;
  owner?: string;
  scope?: string;
  groups?: string[];
  values: Record<string, any>;
}

interface Meta { id: string; name: string; version: number; setId?: string; enabled?: boolean; owner?: string; scope?: string; groups?: string[]; }

// ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation
// logic bound to a set, run when instances of that set are saved.
interface ConfigRule {
  id: string;
  name: string;
  setId: string;
  description?: string;
  enabled?: boolean;
  version: number;
  tag?: string;
  owner?: string;
  source: string;
}

// RuleViolation / RuleResult mirror the backend evaluation payload.
interface RuleViolation { rule?: string; path?: string; message: string; }
interface RuleResult { ok: boolean; violations?: RuleViolation[]; transformed?: Record<string, any>; compileError?: string; }

// Snapshot / preview payloads mirror the backend /config/rules/preview response.
interface SnapshotEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; error?: string; }
interface SnapshotResult { setId: string; entries: SnapshotEntry[]; captured: number; failed: number; }
interface RulePreview { snapshot: SnapshotResult; result: RuleResult; }

// A rule with no explicit enabled flag (legacy) is treated as enabled.
const ruleEnabled = (r: { enabled?: boolean }) => r.enabled !== false;

interface ApplyEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; skipped?: boolean; error?: string; }
interface ApplyResult { instanceId: string; setId: string; entries: ApplyEntry[]; applied: number; failed: number; skipped: number; }

type DiffStatus = 'added' | 'removed' | 'changed' | 'unchanged';
interface SetDiffEntry { key: string; status: DiffStatus; left?: Parameter; right?: Parameter; }
interface InstanceDiffEntry { key: string; status: DiffStatus; left?: any; right?: any; }

const TYPE_LABELS: Record<ParamType, string> = {
  float64: 'Number', int64: 'Integer', bool: 'Boolean', string: 'String', enum: 'Enum', 'float64[]': 'Array',
};

// SignalInfo mirrors the /api/v1/signals payload (internal/api signalInfo).
interface SignalInfo {
  name: string;
  type: string;
  unit?: string;
  displayLow: number;
  displayHigh: number;
  writable: boolean;
  enumStrings?: string[];
  description?: string;
}

// metaToParamType maps a signal's reported type string to a config ParamType.
function metaToParamType(t: string): ParamType {
  switch (t) {
    case 'float64[]': return 'float64[]';
    case 'int64': return 'int64';
    case 'bool': return 'bool';
    case 'enum': return 'enum';
    case 'string': return 'string';
    default: return 'float64';
  }
}

// ── API helper ───────────────────────────────────────────────────────────────

async function apiJSON<T = any>(url: string, opts?: RequestInit): Promise<T | null> {
  const res = await fetch(url, opts);
  if (!res.ok && res.status !== 204) {
    let msg = `HTTP ${res.status}`;
    try { const e = await res.json(); if (e && e.error) msg = e.error; } catch { /* ignore */ }
    throw new Error(msg);
  }
  if (res.status === 204) return null;
  return res.json();
}

// Lazily-loaded data-source + signal options powering the SignalPicker.
function useSignals() {
  const [dataSources, setDataSources] = useState<string[]>([]);
  const [dsSignals, setDsSignals] = useState<Record<string, SignalInfo[]>>({});

  useEffect(() => {
    fetch('/api/v1/datasources')
      .then(r => (r.ok ? r.json() : []))
      .then((ds: { name: string }[]) => setDataSources(ds.map(d => d.name)))
      .catch(() => {});
  }, []);

  const loadSignals = useCallback((ds: string) => {
    if (!ds) return;
    setDsSignals(prev => {
      if (prev[ds]) return prev;
      fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`)
        .then(r => (r.ok ? r.json() : []))
        .then((sigs: SignalInfo[]) => setDsSignals(p => ({ ...p, [ds]: sigs })))
        .catch(() => {});
      return prev;
    });
  }, []);

  function allOptions(): SignalOption[] {
    const out: SignalOption[] = [];
    for (const ds of dataSources) for (const s of dsSignals[ds] ?? []) out.push({ ds, name: s.name });
    return out;
  }
  function openAll() { dataSources.forEach(loadSignals); }
  function signalMeta(ds: string, name: string): SignalInfo | undefined {
    return (dsSignals[ds] ?? []).find(s => s.name === name);
  }

  return { allOptions, openAll, signalMeta };
}

function splitRef(ref: string): { ds: string; signal: string } {
  const i = ref.indexOf(':');
  if (i < 0) return { ds: '', signal: ref };
  return { ds: ref.slice(0, i), signal: ref.slice(i + 1) };
}

// ── Top-level modal ───────────────────────────────────────────────────────────

interface Props { onClose: () => void; }

// CloseGuard lets the active manager intercept a close/tab-switch request to
// prompt about unsaved changes before the action proceeds.
type CloseGuard = (proceed: () => void) => void;

export default function ConfigManager({ onClose }: Props) {
  const [tab, setTab] = useState<'sets' | 'instances' | 'rules'>('sets');
  const guardRef = useRef<CloseGuard | null>(null);
  const registerGuard = useCallback((fn: CloseGuard | null) => { guardRef.current = fn; }, []);

  // Route close/tab-switch through the active manager's guard so it can prompt
  // before discarding unsaved edits.
  const guarded = (proceed: () => void) => { if (guardRef.current) guardRef.current(proceed); else proceed(); };
  const requestClose = () => guarded(onClose);
  const switchTab = (t: 'sets' | 'instances' | 'rules') => { if (t !== tab) guarded(() => setTab(t)); };

  return (
    <div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
      <div class="cl-modal cl-modal-full">
        <header class="cl-header">
          <span class="cl-title">Configuration manager</span>
          <span class="hint cl-subtitle">Versioned configuration sets (schemas) and instances (values) that apply to signals.</span>
          <div class="cl-header-actions">
            <button class="panel-btn" onClick={requestClose}>Close</button>
          </div>
        </header>

        <div class="cfg-tabs">
          <button class={`cfg-tab${tab === 'sets' ? ' cfg-tab-active' : ''}`} onClick={() => switchTab('sets')}>Sets</button>
          <button class={`cfg-tab${tab === 'instances' ? ' cfg-tab-active' : ''}`} onClick={() => switchTab('instances')}>Instances</button>
          <button class={`cfg-tab${tab === 'rules' ? ' cfg-tab-active' : ''}`} onClick={() => switchTab('rules')}>Rules</button>
        </div>

        {tab === 'sets' && <SetsManager registerGuard={registerGuard} />}
        {tab === 'instances' && <InstancesManager registerGuard={registerGuard} />}
        {tab === 'rules' && <RulesManager registerGuard={registerGuard} />}
      </div>
    </div>
  );
}

// useCloseGuard registers a guard that prompts (via the returned dialog state)
// when there are unsaved changes before a close/tab-switch proceeds.
function useCloseGuard(registerGuard: (fn: CloseGuard | null) => void, dirty: boolean) {
  const [pending, setPending] = useState<{ proceed: () => void } | null>(null);
  useEffect(() => {
    registerGuard((proceed) => { if (dirty) setPending({ proceed }); else proceed(); });
    return () => registerGuard(null);
  }, [dirty, registerGuard]);
  return { pending, setPending };
}

// UnsavedDialog offers Save / Discard / Cancel when closing with pending edits.
function UnsavedDialog({ busy, onSave, onDiscard, onCancel }: {
  busy: boolean; onSave: () => void; onDiscard: () => void; onCancel: () => void;
}) {
  return (
    <div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
      <div class="cl-confirm">
        <div class="cl-confirm-title">Unsaved changes</div>
        <div class="hint">This configuration has unsaved changes. What would you like to do?</div>
        <div class="cl-confirm-actions">
          <button class="panel-btn" disabled={busy} onClick={onCancel}>Cancel</button>
          <button class="panel-btn" disabled={busy} onClick={onDiscard}>Discard</button>
          <button class="panel-btn panel-btn-primary" disabled={busy} onClick={onSave}>Save</button>
        </div>
      </div>
    </div>
  );
}

// useUndo holds an undoable value (the working config). `load` replaces it
// without history (selecting/saving/creating); `commit` records an edit so it
// can be undone/redone. Snapshots are whole values, which is fine for the small
// config documents edited here.
function useUndo<T>() {
  const [past, setPast] = useState<T[]>([]);
  const [present, setPresent] = useState<T | null>(null);
  const [future, setFuture] = useState<T[]>([]);

  function load(v: T | null) { setPresent(v); setPast([]); setFuture([]); }
  function commit(next: T) {
    if (present != null) setPast(p => [...p, present]);
    setPresent(next);
    setFuture([]);
  }
  function undo() {
    if (past.length === 0) return;
    const prev = past[past.length - 1];
    setPast(past.slice(0, -1));
    if (present != null) setFuture([present, ...future]);
    setPresent(prev);
  }
  function redo() {
    if (future.length === 0) return;
    const next = future[0];
    setFuture(future.slice(1));
    if (present != null) setPast([...past, present]);
    setPresent(next);
  }
  return { present, load, commit, undo, redo, canUndo: past.length > 0, canRedo: future.length > 0 };
}

// Bind Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Shift+Z / Ctrl+Y (redo) while a manager is
// active. Native text-edit undo is left alone when a field is focused.
function useUndoKeys(undo: () => void, redo: () => void, canUndo: boolean, canRedo: boolean) {
  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      const t = e.target as Element | null;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
      const mod = e.ctrlKey || e.metaKey;
      if (!mod) return;
      const k = e.key.toLowerCase();
      if (k === 'z' && !e.shiftKey) { if (canUndo) { e.preventDefault(); e.stopImmediatePropagation(); undo(); } }
      else if ((k === 'y') || (k === 'z' && e.shiftKey)) { if (canRedo) { e.preventDefault(); e.stopImmediatePropagation(); redo(); } }
    }
    window.addEventListener('keydown', onKey, true);
    return () => window.removeEventListener('keydown', onKey, true);
  }, [undo, redo, canUndo, canRedo]);
}

// ── Sets manager ──────────────────────────────────────────────────────────────

function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
  const me = useAuth();
  const [sets, setSets] = useState<Meta[]>([]);
  const [bucket, setBucket] = useState<Bucket>('mine');
  const [scopeGroup, setScopeGroup] = useState('');
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const hist = useUndo<ConfigSet>();
  const working = hist.present;
  const [dirty, setDirty] = useState(false);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [diff, setDiff] = useState<{ title: string; entries: SetDiffEntry[] } | null>(null);
  const [viewingVersion, setViewingVersion] = useState<number | null>(null);
  const [verReload, setVerReload] = useState(0);
  const { allOptions, openAll, signalMeta } = useSignals();
  const { pending, setPending } = useCloseGuard(registerGuard, dirty);
  const fileRef = useRef<HTMLInputElement>(null);

  function undo() { hist.undo(); setDirty(true); }
  function redo() { hist.redo(); setDirty(true); }
  useUndoKeys(undo, redo, hist.canUndo, hist.canRedo);

  const reload = useCallback(async () => {
    try { setSets((await apiJSON<Meta[]>('/api/v1/config/sets')) ?? []); }
    catch (err) { setError(`Failed to load sets: ${msg(err)}`); }
  }, []);
  useEffect(() => { reload(); }, [reload]);

  async function select(id: string) {
    if (dirty && !confirm('Discard unsaved changes?')) return;
    setError(null);
    try {
      const s = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(id)}`);
      setSelectedId(id);
      hist.load(s);
      setDirty(false);
    } catch (err) { setError(msg(err)); }
  }

  async function createSet() {
    setBusy(true); setError(null);
    try {
      const body = { name: 'New config set', description: '', parameters: [] };
      const created = await apiJSON<ConfigSet>('/api/v1/config/sets', jsonPost(body));
      await reload();
      setSelectedId(created!.id);
      hist.load(created);
      setDirty(false);
    } catch (err) { setError(`Create failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  // Export the selected set's current schema as a downloadable JSON file.
  function exportSet() {
    if (!working) return;
    const payload = { name: working.name, description: working.description ?? '', parameters: working.parameters };
    const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `${(working.name || 'config-set').replace(/[^\w.-]+/g, '_')}.json`;
    a.click();
    URL.revokeObjectURL(url);
  }

  // Import a JSON file as a new config set (its name/description/parameters).
  async function importSet(file: File) {
    setBusy(true); setError(null);
    try {
      const parsed = JSON.parse(await file.text());
      if (!parsed || !Array.isArray(parsed.parameters)) throw new Error('not a valid config set (missing parameters array)');
      const body = { name: parsed.name || file.name.replace(/\.json$/i, '') || 'Imported set', description: parsed.description || '', parameters: parsed.parameters };
      const created = await apiJSON<ConfigSet>('/api/v1/config/sets', jsonPost(body));
      await reload();
      setSelectedId(created!.id);
      hist.load(created);
      setDirty(false);
    } catch (err) { setError(`Import failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  async function save(): Promise<boolean> {
    if (!working) return false;
    setBusy(true); setError(null);
    try {
      const saved = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(working.id)}`, jsonPut(working));
      hist.load(saved);
      setDirty(false);
      setViewingVersion(null);
      setVerReload(k => k + 1);
      await reload();
      return true;
    } catch (err) { setError(`Save failed: ${msg(err)}`); return false; }
    finally { setBusy(false); }
  }

  async function remove(id: string) {
    if (!confirm('Delete this config set? A server-side copy is kept in trash.')) return;
    setBusy(true); setError(null);
    try {
      await apiJSON(`/api/v1/config/sets/${encodeURIComponent(id)}`, { method: 'DELETE' });
      if (selectedId === id) { setSelectedId(null); hist.load(null); setDirty(false); }
      await reload();
    } catch (err) { setError(`Delete failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  function patch(p: Partial<ConfigSet>) {
    if (!working) return;
    hist.commit({ ...working, ...p });
    setDirty(true);
  }
  function patchParam(i: number, p: Partial<Parameter>) {
    if (!working) return;
    const params = working.parameters.map((x, idx) => (idx === i ? { ...x, ...p } : x));
    patch({ parameters: params });
  }
  function removeParam(i: number) {
    if (!working) return;
    patch({ parameters: working.parameters.filter((_, idx) => idx !== i) });
  }

  async function showDiff(av: number, bv: number) {
    if (!working) return;
    setError(null);
    try {
      const q = new URLSearchParams({ a: working.id, av: String(av), b: working.id, bv: String(bv) });
      const entries = (await apiJSON<SetDiffEntry[]>(`/api/v1/config/sets/diff?${q}`)) ?? [];
      setDiff({ title: `${working.name}: v${av} → v${bv}`, entries });
    } catch (err) { setError(`Diff failed: ${msg(err)}`); }
  }

  // Load a past revision into the editor (non-destructive; saving promotes it to
  // a new revision). Viewing alone is not a change, so the editor stays clean.
  async function viewVersion(version: number) {
    if (!working) return;
    if (dirty && !confirm('Discard unsaved changes to view this version?')) return;
    setError(null);
    try {
      const s = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(working.id)}/versions/${version}`);
      hist.load(s);
      setViewingVersion(version);
      setDirty(false);
    } catch (err) { setError(`Load version failed: ${msg(err)}`); }
  }
  async function reloadCurrent() {
    if (!working) return;
    try {
      const s = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(working.id)}`);
      hist.load(s);
      setViewingVersion(null);
      setDirty(false);
      setVerReload(k => k + 1);
      await reload();
    } catch (err) { setError(msg(err)); }
  }

  const visibleSets = filterByScope(sets, me, bucket, scopeGroup);

  return (
    <div class="cl-body">
      <div class="cl-list">
        <div class="cl-list-head">
          <span>Config sets</span>
          <div class="cfg-list-actions">
            <button class="panel-btn" disabled={busy} title="Import a config set from a JSON file" onClick={() => fileRef.current?.click()}>Import</button>
            <button class="panel-btn" disabled={busy} onClick={createSet}>+ New</button>
          </div>
          <input ref={fileRef} type="file" accept="application/json,.json" style={{ display: 'none' }}
            onChange={(e) => { const t = e.target as HTMLInputElement; const f = t.files?.[0]; if (f) importSet(f); t.value = ''; }} />
        </div>
        <ScopeFilter me={me} value={bucket} onChange={setBucket} group={scopeGroup} onGroupChange={setScopeGroup} />
        {sets.length === 0 && <div class="hint cl-list-empty">No config sets yet.</div>}
        {sets.length > 0 && visibleSets.length === 0 && <div class="hint cl-list-empty">No config sets in this view.</div>}
        {visibleSets.map(s => (
          <div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
            <span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
            <span class="cfg-badge" title="Current version">v{s.version}</span>
            <button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}>✕</button>
          </div>
        ))}
      </div>

      <div class="cl-editor-wrap">
        {error && <div class="cl-error">{error}</div>}
        {!working && <div class="cl-empty hint">Select a config set on the left, or create a new one.</div>}
        {working && (
          <div class="cfg-editor">
            <div class="cfg-edit-bar">
              <input class="prop-input cl-name-input" value={working.name} placeholder="Set name"
                onInput={(e) => patch({ name: (e.target as HTMLInputElement).value })} />
              <span class="cfg-badge">v{working.version}</span>
              {dirty && <span class="cl-dirty">unsaved</span>}
              <button class="panel-btn" disabled={!hist.canUndo} title="Undo (Ctrl+Z)" onClick={undo}>↩</button>
              <button class="panel-btn" disabled={!hist.canRedo} title="Redo (Ctrl+Shift+Z)" onClick={redo}>↪</button>
              <button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
              <button class="panel-btn" disabled={busy} title="Export this config set as JSON" onClick={exportSet}>Export</button>
            </div>
            <textarea class="prop-input cfg-desc" rows={2} value={working.description ?? ''} placeholder="Description (optional)"
              onInput={(e) => patch({ description: (e.target as HTMLTextAreaElement).value })} />
            <ScopePicker me={me} scope={normalizeScope(working.scope)} groups={working.groups ?? []}
              onChange={(scope, groups) => patch({ scope, groups })} />

            <div class="cfg-section-head">
              <span>Parameters</span>
            </div>
            <ParamTree params={working.parameters} allOptions={allOptions()} onOpenSignals={openAll} signalMeta={signalMeta}
              onReplace={(ps) => patch({ parameters: ps })} onPatchParam={patchParam} onRemoveParam={removeParam} />

            <VersionPanel kind="sets" id={working.id}
              viewing={viewingVersion} dirty={dirty} reloadKey={verReload}
              onView={viewVersion}
              onForked={(id) => { reload(); select(id); }}
              onPromoted={reloadCurrent}
              onCompare={showDiff} onError={setError} />
          </div>
        )}
      </div>

      {diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
        <SetDiffTable entries={diff.entries} />
      </DiffModal>}

      {pending && (
        <UnsavedDialog busy={busy}
          onCancel={() => setPending(null)}
          onDiscard={() => { const p = pending.proceed; setDirty(false); setPending(null); p(); }}
          onSave={async () => { const ok = await save(); if (ok) { const p = pending.proceed; setPending(null); p(); } }} />
      )}
    </div>
  );
}

// ── Parameter tree (group / subgroup organiser) ────────────────────────────────

interface TreeSub { name: string; items: { p: Parameter; idx: number }[]; }
interface TreeGroup { name: string; subs: TreeSub[]; }

// buildParamTree clusters the flat parameter array by group then subgroup,
// preserving first-appearance order. The array order is the canonical order.
function buildParamTree(params: Parameter[]): TreeGroup[] {
  const groups: TreeGroup[] = [];
  const gmap = new Map<string, TreeGroup>();
  params.forEach((p, idx) => {
    const gn = p.group || '';
    let g = gmap.get(gn);
    if (!g) { g = { name: gn, subs: [] }; gmap.set(gn, g); groups.push(g); }
    const sn = p.subgroup || '';
    let s = g.subs.find(x => x.name === sn);
    if (!s) { s = { name: sn, items: [] }; g.subs.push(s); }
    s.items.push({ p, idx });
  });
  return groups;
}

// moveParam removes the dragged parameter and reinserts it under the destination
// group/subgroup — before a specific parameter when beforeKey is given, else at
// the end of that region.
function moveParam(params: Parameter[], draggedKey: string, dest: { group: string; subgroup: string; beforeKey?: string }): Parameter[] {
  const dragged = params.find(p => p.key === draggedKey);
  if (!dragged) return params;
  const moved: Parameter = { ...dragged };
  if (dest.group) moved.group = dest.group; else delete moved.group;
  if (dest.subgroup) moved.subgroup = dest.subgroup; else delete moved.subgroup;
  const rest = params.filter(p => p.key !== draggedKey);
  let at: number;
  if (dest.beforeKey) {
    at = rest.findIndex(p => p.key === dest.beforeKey);
    if (at < 0) at = rest.length;
  } else {
    let last = -1;
    rest.forEach((p, i) => { if ((p.group || '') === dest.group && (p.subgroup || '') === dest.subgroup) last = i; });
    at = last >= 0 ? last + 1 : rest.length;
  }
  rest.splice(at, 0, moved);
  return rest;
}

function ParamTree({ params, allOptions, onOpenSignals, signalMeta, onReplace, onPatchParam, onRemoveParam }: {
  params: Parameter[];
  allOptions: SignalOption[];
  onOpenSignals: () => void;
  signalMeta: (ds: string, name: string) => SignalInfo | undefined;
  onReplace: (params: Parameter[]) => void;
  onPatchParam: (idx: number, p: Partial<Parameter>) => void;
  onRemoveParam: (idx: number) => void;
}) {
  const [expanded, setExpanded] = useState<Set<number>>(new Set());
  const [dragKey, setDragKey] = useState<string | null>(null);
  const [overId, setOverId] = useState<string | null>(null);

  const tree = buildParamTree(params);

  function toggle(idx: number) {
    setExpanded(prev => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; });
  }

  function uniqueKey(): string {
    const keys = new Set(params.map(p => p.key));
    let n = params.length + 1; while (keys.has(`param${n}`)) n++;
    return `param${n}`;
  }
  function uniqueGroup(): string {
    const names = new Set(params.map(p => p.group || ''));
    let n = 1; while (names.has(`Group ${n}`)) n++;
    return `Group ${n}`;
  }
  function uniqueSub(group: string): string {
    const names = new Set(params.filter(p => (p.group || '') === group).map(p => p.subgroup || ''));
    let n = 1; while (names.has(`Subgroup ${n}`)) n++;
    return `Subgroup ${n}`;
  }

  function addParam(group: string, subgroup: string) {
    const key = uniqueKey();
    const np: Parameter = { key, ds: '', signal: '', type: 'float64' };
    if (group) np.group = group;
    if (subgroup) np.subgroup = subgroup;
    const newIdx = params.length;
    onReplace([...params, np]);
    setExpanded(prev => new Set(prev).add(newIdx));
  }
  function renameGroup(oldName: string, newName: string) {
    onReplace(params.map(p => (p.group || '') === oldName ? { ...p, group: newName || undefined } : p));
  }
  function renameSub(group: string, oldSub: string, newSub: string) {
    onReplace(params.map(p => ((p.group || '') === group && (p.subgroup || '') === oldSub) ? { ...p, subgroup: newSub || undefined } : p));
  }
  function drop(dest: { group: string; subgroup: string; beforeKey?: string }) {
    if (dragKey) onReplace(moveParam(params, dragKey, dest));
    setDragKey(null); setOverId(null);
  }

  function renderParam(group: string, subgroup: string, p: Parameter, idx: number) {
    const open = expanded.has(idx);
    return (
      <div key={idx}
        class={`cfg-tree-param${dragKey === p.key ? ' dragging' : ''}${overId === p.key ? ' drop-over' : ''}`}
        onDragOver={(e) => { if (dragKey) { e.preventDefault(); setOverId(p.key); } }}
        onDragLeave={() => setOverId(o => (o === p.key ? null : o))}
        onDrop={(e) => { e.preventDefault(); e.stopPropagation(); drop({ group, subgroup, beforeKey: p.key }); }}>
        <div class="cfg-tree-phead" draggable
          onDragStart={() => setDragKey(p.key)} onDragEnd={() => { setDragKey(null); setOverId(null); }}>
          <span class="cfg-drag-handle" title="Drag to reorder or move between groups">⠿</span>
          <button class="cfg-tree-toggle" onClick={() => toggle(idx)}>{open ? '▾' : '▸'}</button>
          <span class="cfg-tree-pkey">{p.key}</span>
          {p.label && <span class="cfg-tree-plabel">{p.label}</span>}
          <span class="cfg-tree-ptype">{TYPE_LABELS[p.type]}</span>
          <span class="cfg-tree-psig hint">{p.ds && p.signal ? `${p.ds}:${p.signal}` : 'no signal'}</span>
        </div>
        {open && (
          <ParamEditor param={p} allOptions={allOptions} onOpenSignals={onOpenSignals} signalMeta={signalMeta}
            onChange={(patch2) => onPatchParam(idx, patch2)} onRemove={() => onRemoveParam(idx)} />
        )}
      </div>
    );
  }

  return (
    <div class="cfg-tree">
      <div class="cfg-tree-toolbar">
        <button class="panel-btn" onClick={() => addParam('', '')}>+ Parameter</button>
        <button class="panel-btn" onClick={() => addParam(uniqueGroup(), '')}>+ Group</button>
      </div>
      {params.length === 0 && <div class="hint">No parameters. Each parameter binds a target signal with a default and constraints. Drag to organise into groups.</div>}

      {tree.map((g, gi) => (
        <div key={gi} class="cfg-tree-group">
          <div class={`cfg-tree-ghead${overId === `g:${g.name}` ? ' drop-over' : ''}`}
            onDragOver={(e) => { if (dragKey) { e.preventDefault(); setOverId(`g:${g.name}`); } }}
            onDragLeave={() => setOverId(o => (o === `g:${g.name}` ? null : o))}
            onDrop={(e) => { e.preventDefault(); drop({ group: g.name, subgroup: '' }); }}>
            {g.name === ''
              ? <span class="cfg-tree-glabel">Ungrouped</span>
              : <input class="prop-input cfg-tree-name" value={g.name}
                  onInput={(e) => renameGroup(g.name, (e.target as HTMLInputElement).value)} />}
            <div class="cfg-tree-ghead-actions">
              <button class="cl-mini-btn" title="Add parameter to this group" onClick={() => addParam(g.name, '')}>+ Param</button>
              <button class="cl-mini-btn" title="Add subgroup" onClick={() => addParam(g.name, uniqueSub(g.name))}>+ Subgroup</button>
            </div>
          </div>
          {g.subs.map((s, si) => (
            s.name === '' ? (
              <div key={si} class="cfg-tree-items">
                {s.items.map(it => renderParam(g.name, '', it.p, it.idx))}
              </div>
            ) : (
              <div key={si} class="cfg-tree-sub">
                <div class={`cfg-tree-shead${overId === `s:${g.name}/${s.name}` ? ' drop-over' : ''}`}
                  onDragOver={(e) => { if (dragKey) { e.preventDefault(); setOverId(`s:${g.name}/${s.name}`); } }}
                  onDragLeave={() => setOverId(o => (o === `s:${g.name}/${s.name}` ? null : o))}
                  onDrop={(e) => { e.preventDefault(); drop({ group: g.name, subgroup: s.name }); }}>
                  <span class="cfg-tree-sicon">└</span>
                  <input class="prop-input cfg-tree-name" value={s.name}
                    onInput={(e) => renameSub(g.name, s.name, (e.target as HTMLInputElement).value)} />
                  <button class="cl-mini-btn" title="Add parameter to this subgroup" onClick={() => addParam(g.name, s.name)}>+ Param</button>
                </div>
                <div class="cfg-tree-items">
                  {s.items.map(it => renderParam(g.name, s.name, it.p, it.idx))}
                </div>
              </div>
            )
          ))}
        </div>
      ))}
    </div>
  );
}

function ParamEditor({ param, allOptions, onOpenSignals, signalMeta, onChange, onRemove }: {
  param: Parameter;
  allOptions: SignalOption[];
  onOpenSignals: () => void;
  signalMeta: (ds: string, name: string) => SignalInfo | undefined;
  onChange: (p: Partial<Parameter>) => void;
  onRemove: () => void;
}) {
  const ref = param.ds || param.signal ? `${param.ds}:${param.signal}` : '';
  const isNum = param.type === 'float64' || param.type === 'int64';
  const isArray = param.type === 'float64[]';
  const showRange = isNum || isArray;

  // Picking a target signal auto-fills type/unit/range/enum from the signal's
  // reported metadata when available — the editor stays editable afterwards.
  function pickTarget(r: string) {
    const s = splitRef(r);
    const p: Partial<Parameter> = { ds: s.ds, signal: s.signal };
    const meta = signalMeta(s.ds, s.signal);
    if (meta) {
      p.type = metaToParamType(meta.type);
      if (meta.unit) p.unit = meta.unit;
      if (meta.enumStrings && meta.enumStrings.length) p.enumValues = meta.enumStrings;
      if (meta.displayLow !== meta.displayHigh) { p.min = meta.displayLow; p.max = meta.displayHigh; }
    }
    onChange(p);
  }

  return (
    <div class="cfg-param">
      <div class="cfg-param-row">
        <div class="cfg-field cfg-field-key">
          <label>Key</label>
          <input class="prop-input" value={param.key} onInput={(e) => onChange({ key: (e.target as HTMLInputElement).value })} />
        </div>
        <div class="cfg-field cfg-field-label">
          <label>Label</label>
          <input class="prop-input" value={param.label ?? ''} placeholder="(optional)"
            onInput={(e) => onChange({ label: (e.target as HTMLInputElement).value })} />
        </div>
        <div class="cfg-field cfg-field-type">
          <label>Type</label>
          <div class="cfg-type-ro" title="Determined automatically from the bound signal">{TYPE_LABELS[param.type]}</div>
        </div>
        <button class="cl-mini-btn cfg-param-del" title="Remove parameter" onClick={onRemove}>✕</button>
      </div>

      <div class="cfg-param-row">
        <div class="cfg-field cfg-field-target">
          <label>Target signal</label>
          <SignalPicker value={ref} options={allOptions} onOpen={onOpenSignals} allowFreeform onChange={pickTarget} />
        </div>
        <div class="cfg-field cfg-field-default">
          <label>Default</label>
          {param.type === 'bool' ? (
            <select class="prop-select" value={String(param.default ?? '')}
              onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange({ default: v === '' ? undefined : v === 'true' }); }}>
              <option value="">(none)</option>
              <option value="true">true</option>
              <option value="false">false</option>
            </select>
          ) : isArray ? (
            <input class="prop-input" type="text" placeholder="1, 2, 3"
              value={Array.isArray(param.default) ? (param.default as number[]).join(', ') : ''}
              onInput={(e) => onChange({ default: coerceVal((e.target as HTMLInputElement).value, param.type) })} />
          ) : (
            <input class="prop-input" type={isNum ? 'number' : 'text'} value={param.default ?? ''}
              onInput={(e) => onChange({ default: coerceVal((e.target as HTMLInputElement).value, param.type) })} />
          )}
        </div>
        <label class="cfg-mandatory">
          <input type="checkbox" checked={!!param.mandatory} onChange={(e) => onChange({ mandatory: (e.target as HTMLInputElement).checked })} />
          Mandatory
        </label>
      </div>

      <div class="cfg-param-row">
        <div class="cfg-field">
          <label>Unit</label>
          <input class="prop-input" value={param.unit ?? ''} placeholder="(optional)"
            onInput={(e) => onChange({ unit: (e.target as HTMLInputElement).value })} />
        </div>
        {showRange && (
          <Fragment>
            <div class="cfg-field cfg-field-num">
              <label>Min{isArray ? ' (per pt)' : ''}</label>
              <input class="prop-input" type="number" value={param.min ?? ''}
                onInput={(e) => onChange({ min: numOrUndef((e.target as HTMLInputElement).value) })} />
            </div>
            <div class="cfg-field cfg-field-num">
              <label>Max</label>
              <input class="prop-input" type="number" value={param.max ?? ''}
                onInput={(e) => onChange({ max: numOrUndef((e.target as HTMLInputElement).value) })} />
            </div>
          </Fragment>
        )}
        {param.type === 'enum' && (
          <div class="cfg-field cfg-field-enum">
            <label>Enum values (comma-separated)</label>
            <input class="prop-input" value={(param.enumValues ?? []).join(', ')} placeholder="a, b, c"
              onInput={(e) => onChange({ enumValues: (e.target as HTMLInputElement).value.split(',').map(s => s.trim()).filter(Boolean) })} />
          </div>
        )}
      </div>
    </div>
  );
}

// ── Instances manager ─────────────────────────────────────────────────────────

function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
  const me = useAuth();
  const [instances, setInstances] = useState<Meta[]>([]);
  const [sets, setSets] = useState<Meta[]>([]);
  const [setFilter, setSetFilter] = useState<string>('');
  const [bucket, setBucket] = useState<Bucket>('mine');
  const [scopeGroup, setScopeGroup] = useState('');
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const hist = useUndo<ConfigInstance>();
  const working = hist.present;
  const [boundSet, setBoundSet] = useState<ConfigSet | null>(null);
  const [dirty, setDirty] = useState(false);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
  const [diff, setDiff] = useState<{ title: string; entries: InstanceDiffEntry[] } | null>(null);
  const [showNew, setShowNew] = useState(false);
  const [showSnap, setShowSnap] = useState(false);
  const [viewingVersion, setViewingVersion] = useState<number | null>(null);
  const [verReload, setVerReload] = useState(0);
  const { pending, setPending } = useCloseGuard(registerGuard, dirty);

  function undo() { hist.undo(); setDirty(true); }
  function redo() { hist.redo(); setDirty(true); }
  useUndoKeys(undo, redo, hist.canUndo, hist.canRedo);

  const reload = useCallback(async () => {
    try {
      setInstances((await apiJSON<Meta[]>('/api/v1/config/instances')) ?? []);
      setSets((await apiJSON<Meta[]>('/api/v1/config/sets')) ?? []);
    } catch (err) { setError(`Failed to load: ${msg(err)}`); }
  }, []);
  useEffect(() => { reload(); }, [reload]);

  async function loadSet(setId: string, setVersion?: number): Promise<ConfigSet | null> {
    const path = setVersion && setVersion > 0
      ? `/api/v1/config/sets/${encodeURIComponent(setId)}/versions/${setVersion}`
      : `/api/v1/config/sets/${encodeURIComponent(setId)}`;
    return apiJSON<ConfigSet>(path);
  }

  async function select(id: string) {
    if (dirty && !confirm('Discard unsaved changes?')) return;
    setError(null); setApplyResult(null);
    try {
      const inst = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(id)}`);
      const set = await loadSet(inst!.setId, inst!.setVersion);
      setSelectedId(id);
      hist.load(inst);
      setBoundSet(set);
      setDirty(false);
    } catch (err) { setError(msg(err)); }
  }

  async function createInstance(name: string, setId: string) {
    setBusy(true); setError(null);
    try {
      const body = { name, setId, values: {} };
      const created = await apiJSON<ConfigInstance>('/api/v1/config/instances', jsonPost(body));
      const set = await loadSet(created!.setId, created!.setVersion);
      await reload();
      setSelectedId(created!.id);
      hist.load(created);
      setBoundSet(set);
      setDirty(false);
      setShowNew(false);
    } catch (err) { setError(`Create failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  async function save(): Promise<boolean> {
    if (!working) return false;
    setBusy(true); setError(null);
    try {
      const saved = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(working.id)}`, jsonPut(working));
      hist.load(saved);
      setDirty(false);
      setViewingVersion(null);
      setVerReload(k => k + 1);
      await reload();
      return true;
    } catch (err) { setError(`Save failed: ${msg(err)}`); return false; }
    finally { setBusy(false); }
  }

  async function remove(id: string) {
    if (!confirm('Delete this config instance? A server-side copy is kept in trash.')) return;
    setBusy(true); setError(null);
    try {
      await apiJSON(`/api/v1/config/instances/${encodeURIComponent(id)}`, { method: 'DELETE' });
      if (selectedId === id) { setSelectedId(null); hist.load(null); setBoundSet(null); setDirty(false); }
      await reload();
    } catch (err) { setError(`Delete failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  async function apply() {
    if (!working) return;
    if (!confirm('Apply this configuration? Each value is written to its target signal.')) return;
    setBusy(true); setError(null); setApplyResult(null);
    try {
      const res = await apiJSON<ApplyResult>(`/api/v1/config/instances/${encodeURIComponent(working.id)}/apply`, { method: 'POST' });
      setApplyResult(res);
    } catch (err) { setError(`Apply failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  function setValue(key: string, value: any) {
    if (!working) return;
    const values = { ...working.values };
    if (value === undefined) delete values[key];
    else values[key] = value;
    hist.commit({ ...working, values });
    setDirty(true);
  }

  async function showDiff(av: number, bv: number) {
    if (!working) return;
    setError(null);
    try {
      const q = new URLSearchParams({ a: working.id, av: String(av), b: working.id, bv: String(bv) });
      const entries = (await apiJSON<InstanceDiffEntry[]>(`/api/v1/config/instances/diff?${q}`)) ?? [];
      setDiff({ title: `${working.name}: v${av} → v${bv}`, entries });
    } catch (err) { setError(`Diff failed: ${msg(err)}`); }
  }

  // Compare the stored instance values against the current live signal values.
  async function liveDiff() {
    if (!working) return;
    setBusy(true); setError(null);
    try {
      const entries = (await apiJSON<InstanceDiffEntry[]>(`/api/v1/config/instances/${encodeURIComponent(working.id)}/livediff`)) ?? [];
      setDiff({ title: `${working.name}: stored → current`, entries });
    } catch (err) { setError(`Live diff failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  // Snapshot a set's current live signal values into a new instance.
  async function snapshot(setId: string, name: string) {
    setBusy(true); setError(null);
    try {
      const body = name ? { name } : {};
      const res = await apiJSON<{ instance: ConfigInstance; snapshot: { captured: number; failed: number } }>(
        `/api/v1/config/sets/${encodeURIComponent(setId)}/snapshot`, jsonPost(body));
      await reload();
      if (res) {
        await select(res.instance.id);
        if (res.snapshot.failed > 0) {
          setError(`Snapshot captured ${res.snapshot.captured}, ${res.snapshot.failed} signal(s) could not be read.`);
        }
      }
      setShowSnap(false);
    } catch (err) { setError(`Snapshot failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  // Load a past revision into the editor (non-destructive; saving promotes it to
  // a new revision). Viewing alone is not a change, so the editor stays clean.
  async function viewVersion(version: number) {
    if (!working) return;
    if (dirty && !confirm('Discard unsaved changes to view this version?')) return;
    setError(null); setApplyResult(null);
    try {
      const inst = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(working.id)}/versions/${version}`);
      const set = await loadSet(inst!.setId, inst!.setVersion);
      hist.load(inst);
      setBoundSet(set);
      setViewingVersion(version);
      setDirty(false);
    } catch (err) { setError(`Load version failed: ${msg(err)}`); }
  }
  async function reloadCurrent() {
    if (!working) return;
    try {
      const inst = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(working.id)}`);
      const set = await loadSet(inst!.setId, inst!.setVersion);
      hist.load(inst);
      setBoundSet(set);
      setViewingVersion(null);
      setDirty(false);
      setVerReload(k => k + 1);
      await reload();
    } catch (err) { setError(msg(err)); }
  }

  const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
  const scopedInstances = filterByScope(instances, me, bucket, scopeGroup);
  const visibleInstances = scopedInstances.filter(s => !setFilter || s.setId === setFilter);

  return (
    <div class="cl-body">
      <div class="cl-list">
        <div class="cl-list-head">
          <span>Instances</span>
          <button class="panel-btn" disabled={busy || sets.length === 0} title="Snapshot a set's current live signal values into a new instance" onClick={() => setShowSnap(true)}>⎙ Snapshot</button>
          <button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
        </div>
        <ScopeFilter me={me} value={bucket} onChange={setBucket} group={scopeGroup} onGroupChange={setScopeGroup} />
        {sets.length > 1 && (
          <div class="cl-list-filter">
            <select class="prop-select" value={setFilter} title="Filter instances by config set"
              onChange={(e) => setSetFilter((e.target as HTMLSelectElement).value)}>
              <option value="">All sets</option>
              {sets.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
            </select>
          </div>
        )}
        {sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
        {sets.length > 0 && instances.length === 0 && <div class="hint cl-list-empty">No instances yet.</div>}
        {sets.length > 0 && instances.length > 0 && visibleInstances.length === 0 && <div class="hint cl-list-empty">No instances in this set.</div>}
        {visibleInstances.map(s => (
          <div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
            <span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
            <span class="cfg-badge">v{s.version}</span>
            <button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}>✕</button>
          </div>
        ))}
      </div>

      <div class="cl-editor-wrap">
        {error && <div class="cl-error">{error}</div>}
        {!working && <div class="cl-empty hint">Select an instance on the left, or create a new one.</div>}
        {working && (
          <div class="cfg-editor">
            <div class="cfg-edit-bar">
              <input class="prop-input cl-name-input" value={working.name} placeholder="Instance name"
                onInput={(e) => { hist.commit({ ...working, name: (e.target as HTMLInputElement).value }); setDirty(true); }} />
              <span class="cfg-badge">v{working.version}</span>
              {dirty && <span class="cl-dirty">unsaved</span>}
              <button class="panel-btn" disabled={!hist.canUndo} title="Undo (Ctrl+Z)" onClick={undo}>↩</button>
              <button class="panel-btn" disabled={!hist.canRedo} title="Redo (Ctrl+Shift+Z)" onClick={redo}>↪</button>
              <button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
              <button class="panel-btn" disabled={busy || dirty} title={dirty ? 'Save before applying' : 'Write values to signals'} onClick={apply}>Apply</button>
              <button class="panel-btn" disabled={busy} title="Compare stored values against the current live signal values" onClick={liveDiff}>Diff vs current</button>
            </div>
            <div class="hint">Based on set <b>{setName(working.setId)}</b>{working.setVersion ? ` (v${working.setVersion})` : ''}.</div>
            <ScopePicker me={me} scope={normalizeScope(working.scope)} groups={working.groups ?? []}
              onChange={(scope, groups) => { hist.commit({ ...working, scope, groups }); setDirty(true); }} />

            {!boundSet && <div class="cl-error">Bound set could not be loaded.</div>}
            {boundSet && (
              <div class="cfg-values">
                {boundSet.parameters.length === 0 && <div class="hint">The bound set has no parameters.</div>}
                {boundSet.parameters.map(p => (
                  <ValueRow key={p.key} param={p} value={working.values[p.key]} onChange={(v) => setValue(p.key, v)} />
                ))}
              </div>
            )}

            {applyResult && <ApplyResultView result={applyResult} />}

            <VersionPanel kind="instances" id={working.id}
              viewing={viewingVersion} dirty={dirty} reloadKey={verReload}
              onView={viewVersion}
              onForked={(id) => { reload(); select(id); }}
              onPromoted={reloadCurrent}
              onCompare={showDiff} onError={setError} />
          </div>
        )}
      </div>

      {showNew && <NewInstanceDialog sets={sets} busy={busy} onCancel={() => setShowNew(false)} onCreate={createInstance} />}

      {showSnap && <SnapshotDialog sets={sets} busy={busy} onCancel={() => setShowSnap(false)} onSnapshot={snapshot} />}

      {diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
        <InstanceDiffTable entries={diff.entries} />
      </DiffModal>}

      {pending && (
        <UnsavedDialog busy={busy}
          onCancel={() => setPending(null)}
          onDiscard={() => { const p = pending.proceed; setDirty(false); setPending(null); p(); }}
          onSave={async () => { const ok = await save(); if (ok) { const p = pending.proceed; setPending(null); p(); } }} />
      )}
    </div>
  );
}

function ValueRow({ param, value, onChange }: { param: Parameter; value: any; onChange: (v: any) => void }) {
  const label = param.label || param.key;
  const isNum = param.type === 'float64' || param.type === 'int64';
  const isArray = param.type === 'float64[]';
  const defText = param.default !== undefined && param.default !== null && !Array.isArray(param.default) ? String(param.default) : '';
  const err = validateValue(param, value);
  return (
    <div class={`cfg-value-row${err ? ' cfg-value-invalid' : ''}`}>
      <div class="cfg-value-label">
        {param.mandatory && <span class="cfg-req" title="Mandatory">*</span>}
        <span title={`${param.ds}:${param.signal}`}>{label}</span>
        {param.unit && <span class="hint cfg-unit">{param.unit}</span>}
        {err && <span class="cfg-value-err" title={err}>⚠</span>}
      </div>
      <div class={`cfg-value-input${isArray ? ' cfg-value-input-array' : ''}`}>
        {param.type === 'bool' ? (
          <select class="prop-select" value={value === undefined ? '' : String(value)}
            onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange(v === '' ? undefined : v === 'true'); }}>
            <option value="">{defText ? `default (${defText})` : '(unset)'}</option>
            <option value="true">true</option>
            <option value="false">false</option>
          </select>
        ) : param.type === 'enum' ? (
          <select class="prop-select" value={value === undefined ? '' : String(value)}
            onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange(v === '' ? undefined : v); }}>
            <option value="">{defText ? `default (${defText})` : '(unset)'}</option>
            {(param.enumValues ?? []).map(ev => <option key={ev} value={ev}>{ev}</option>)}
          </select>
        ) : isArray ? (
          <ArrayValueEditor param={param} value={value} onChange={onChange} />
        ) : (
          <input class="prop-input" type={isNum ? 'number' : 'text'}
            value={value === undefined ? '' : value}
            placeholder={defText ? `default: ${defText}` : '(unset)'}
            onInput={(e) => { const raw = (e.target as HTMLInputElement).value; onChange(raw === '' ? undefined : coerceVal(raw, param.type)); }} />
        )}
      </div>
      <span class="cfg-value-target hint" title="Target signal">{param.ds}:{param.signal}</span>
    </div>
  );
}

// validateValue mirrors the backend's checkValue so missing/out-of-range values
// surface immediately in the editor. Returns null when the value is acceptable.
function validateValue(p: Parameter, value: any): string | null {
  const has = value !== undefined && value !== null;
  if (!has) {
    const hasDefault = p.default !== undefined && p.default !== null;
    if (p.mandatory && !hasDefault) return 'Required: no value set and no default';
    return null;
  }
  if (p.type === 'float64' || p.type === 'int64') {
    const n = typeof value === 'number' ? value : Number(value);
    if (isNaN(n)) return 'Not a number';
    if (p.type === 'int64' && !Number.isInteger(n)) return 'Not an integer';
    if (p.min !== undefined && n < p.min) return `Below minimum ${p.min}`;
    if (p.max !== undefined && n > p.max) return `Above maximum ${p.max}`;
  } else if (p.type === 'enum') {
    if (!(p.enumValues ?? []).includes(String(value))) return 'Not an allowed enum value';
  } else if (p.type === 'float64[]') {
    if (!Array.isArray(value)) return 'Not an array';
    for (let i = 0; i < value.length; i++) {
      const n = value[i];
      if (typeof n !== 'number' || isNaN(n)) return `Element ${i} is not a number`;
      if (p.min !== undefined && n < p.min) return `Element ${i} below minimum ${p.min}`;
      if (p.max !== undefined && n > p.max) return `Element ${i} above maximum ${p.max}`;
    }
  }
  return null;
}

// ArrayValueEditor edits a waveform parameter value: a live sparkline plus
// table / CSV editing and a larger pop-out plot. An empty value falls back to
// the parameter default.
function ArrayValueEditor({ param, value, onChange }: { param: Parameter; value: any; onChange: (v: any) => void }) {
  const arr: number[] = Array.isArray(value) ? value : [];
  const usingDefault = !Array.isArray(value) && Array.isArray(param.default);
  const shown: number[] = arr.length ? arr : (Array.isArray(param.default) ? (param.default as number[]) : []);
  const [mode, setMode] = useState<'none' | 'table' | 'csv'>('none');
  const [csvText, setCsvText] = useState('');
  const [plot, setPlot] = useState(false);

  // Empty array clears the value so the default applies again.
  const commit = (next: number[]) => onChange(next.length ? next : undefined);

  function importCsv() {
    commit(parseArray(csvText) ?? []);
    setMode('none');
  }

  return (
    <div class="cfg-array">
      <div class="cfg-array-bar">
        <Sparkline points={shown} width={120} height={28} />
        <span class="cfg-array-info hint">{shown.length ? `${shown.length} pts` : '(empty)'}{usingDefault ? ' · default' : ''}</span>
        <button class="cl-mini-btn" title="Edit points in a table" onClick={() => setMode(m => m === 'table' ? 'none' : 'table')}>table</button>
        <button class="cl-mini-btn" title="Import from CSV / paste" onClick={() => { setCsvText(shown.join(', ')); setMode(m => m === 'csv' ? 'none' : 'csv'); }}>csv</button>
        <button class="cl-mini-btn" title="Open larger plot" disabled={!shown.length} onClick={() => setPlot(true)}>plot</button>
        {arr.length > 0 && <button class="cl-mini-btn" title="Clear (revert to default)" onClick={() => commit([])}>clear</button>}
      </div>

      {mode === 'table' && (
        <div class="cfg-array-table">
          {arr.length === 0 && <div class="hint">No points. Add one below or import CSV.</div>}
          {arr.map((n, i) => (
            <div class="cfg-array-pt" key={i}>
              <span class="cfg-array-idx hint">{i}</span>
              <input class="prop-input" type="number" value={n}
                onInput={(e) => { const next = arr.slice(); next[i] = Number((e.target as HTMLInputElement).value); commit(next); }} />
              <button class="cl-mini-btn" title="Remove point" onClick={() => commit(arr.filter((_, idx) => idx !== i))}>✕</button>
            </div>
          ))}
          <button class="panel-btn" onClick={() => commit([...arr, 0])}>+ Add point</button>
        </div>
      )}

      {mode === 'csv' && (
        <div class="cfg-array-csv">
          <textarea class="prop-input cfg-array-textarea" rows={3} value={csvText}
            placeholder="Comma, space or newline separated numbers"
            onInput={(e) => setCsvText((e.target as HTMLTextAreaElement).value)} />
          <div class="cl-confirm-actions">
            <button class="panel-btn" onClick={() => setMode('none')}>Cancel</button>
            <button class="panel-btn panel-btn-primary" onClick={importCsv}>Import</button>
          </div>
        </div>
      )}

      {plot && <ArrayPlotModal title={param.label || param.key} points={shown} unit={param.unit} onClose={() => setPlot(false)} />}
    </div>
  );
}

// Sparkline renders a number[] as a simple dependency-free SVG line.
function Sparkline({ points, width, height }: { points: number[]; width: number; height: number }) {
  if (!points.length) return <svg class="cfg-spark" width={width} height={height} />;
  const min = Math.min(...points), max = Math.max(...points);
  const span = max - min || 1;
  const n = points.length;
  const pad = 2;
  const innerH = height - pad * 2;
  const path = points.map((v, i) => {
    const x = n > 1 ? (i / (n - 1)) * width : width / 2;
    const y = pad + innerH - ((v - min) / span) * innerH;
    return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
  }).join(' ');
  return (
    <svg class="cfg-spark" width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
      <path d={path} fill="none" stroke="#4a9eff" stroke-width="1.5" />
    </svg>
  );
}

function ArrayPlotModal({ title, points, unit, onClose }: { title: string; points: number[]; unit?: string; onClose: () => void }) {
  const min = points.length ? Math.min(...points) : 0;
  const max = points.length ? Math.max(...points) : 0;
  const mean = points.length ? points.reduce((a, b) => a + b, 0) / points.length : 0;
  return (
    <div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div class="cfg-diff-modal">
        <div class="cfg-diff-head">
          <span>{title} — {points.length} points{unit ? ` (${unit})` : ''}</span>
          <button class="cl-mini-btn" onClick={onClose}>✕</button>
        </div>
        <div class="cfg-array-plot-body">
          <Sparkline points={points} width={680} height={240} />
          <div class="cfg-array-stats hint">
            <span>min {fmtNum(min)}</span>
            <span>max {fmtNum(max)}</span>
            <span>mean {fmtNum(mean)}</span>
            <span>count {points.length}</span>
          </div>
        </div>
      </div>
    </div>
  );
}

function NewInstanceDialog({ sets, busy, onCancel, onCreate }: {
  sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void;
}) {
  const [name, setName] = useState('New instance');
  const [setId, setSetId] = useState(sets[0]?.id ?? '');
  return (
    <div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
      <div class="cl-confirm">
        <div class="cl-confirm-title">New config instance</div>
        <div class="cfg-field">
          <label>Name</label>
          <input class="prop-input" value={name} autoFocus onInput={(e) => setName((e.target as HTMLInputElement).value)} />
        </div>
        <div class="cfg-field">
          <label>Config set</label>
          <select class="prop-select" value={setId} onChange={(e) => setSetId((e.target as HTMLSelectElement).value)}>
            {sets.map(s => <option key={s.id} value={s.id}>{s.name} (v{s.version})</option>)}
          </select>
        </div>
        <div class="cl-confirm-actions">
          <button class="panel-btn" onClick={onCancel}>Cancel</button>
          <button class="panel-btn panel-btn-primary" disabled={busy || !setId || !name.trim()}
            onClick={() => onCreate(name.trim(), setId)}>Create</button>
        </div>
      </div>
    </div>
  );
}

function SnapshotDialog({ sets, busy, onCancel, onSnapshot }: {
  sets: Meta[]; busy: boolean; onCancel: () => void; onSnapshot: (setId: string, name: string) => void;
}) {
  const [name, setName] = useState('');
  const [setId, setSetId] = useState(sets[0]?.id ?? '');
  return (
    <div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
      <div class="cl-confirm">
        <div class="cl-confirm-title">Snapshot config set</div>
        <div class="cfg-field">
          <label>Config set</label>
          <select class="prop-select" value={setId} onChange={(e) => setSetId((e.target as HTMLSelectElement).value)}>
            {sets.map(s => <option key={s.id} value={s.id}>{s.name} (v{s.version})</option>)}
          </select>
        </div>
        <div class="cfg-field">
          <label>New instance name <span class="hint">(optional)</span></label>
          <input class="prop-input" value={name} autoFocus placeholder="(auto: set name + timestamp)"
            onInput={(e) => setName((e.target as HTMLInputElement).value)} />
        </div>
        <p class="hint">Reads the current live value of every target signal of the chosen set and stores
          them as a new config instance.</p>
        <div class="cl-confirm-actions">
          <button class="panel-btn" onClick={onCancel}>Cancel</button>
          <button class="panel-btn panel-btn-primary" disabled={busy || !setId}
            onClick={() => onSnapshot(setId, name.trim())}>Snapshot</button>
        </div>
      </div>
    </div>
  );
}

function ApplyResultView({ result }: { result: ApplyResult }) {
  return (
    <div class="cfg-apply-result">
      <div class="cfg-apply-head">
        Applied <b>{result.applied}</b>, failed <b class={result.failed ? 'cfg-fail' : ''}>{result.failed}</b>, skipped <b>{result.skipped}</b>
      </div>
      <table class="cfg-apply-table">
        <thead><tr><th>Parameter</th><th>Target</th><th>Value</th><th>Result</th></tr></thead>
        <tbody>
          {result.entries.map(e => (
            <tr key={e.key}>
              <td>{e.key}</td>
              <td class="hint">{e.ds}:{e.signal}</td>
              <td>{e.value !== undefined ? String(e.value) : '—'}</td>
              <td>
                {e.skipped ? <span class="hint">skipped</span>
                  : e.ok ? <span class="cfg-ok">ok</span>
                  : <span class="cfg-fail" title={e.error}>failed</span>}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

// ── Versions panel (shared) ───────────────────────────────────────────────────

function VersionPanel({ kind, id, viewing, dirty, reloadKey, onView, onForked, onPromoted, onCompare, onError }: {
  kind: 'sets' | 'instances' | 'rules';
  id: string;
  viewing: number | null;
  dirty: boolean;
  reloadKey: number;
  onView: (version: number) => void;
  onForked: (newId: string) => void;
  onPromoted: (version: number) => void;
  onCompare: (av: number, bv: number) => void;
  onError: (m: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const base = `/api/v1/config/${kind}/${encodeURIComponent(id)}`;
  // Collapse when switching object.
  useEffect(() => { setOpen(false); }, [id]);

  return (
    <div class="cfg-versions">
      <button class="cfg-versions-toggle" onClick={() => setOpen(o => !o)}>
        {open ? '▾' : '▸'} Version history
      </button>
      {open && (
        <div class="cfg-versions-body">
          <VersionTree
            base={base}
            viewing={viewing}
            dirty={dirty}
            reloadKey={reloadKey}
            onView={onView}
            onForked={onForked}
            onPromoted={onPromoted}
            onCompare={onCompare}
            onError={onError} />
        </div>
      )}
    </div>
  );
}

// ── Diff views ────────────────────────────────────────────────────────────────

function DiffModal({ title, onClose, children }: { title: string; onClose: () => void; children: any }) {
  return (
    <div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div class="cl-confirm cfg-diff-modal">
        <div class="cfg-diff-head">
          <span class="cl-confirm-title">Diff — {title}</span>
          <button class="panel-btn" onClick={onClose}>Close</button>
        </div>
        {children}
      </div>
    </div>
  );
}

function paramSummary(p?: Parameter): string {
  if (!p) return '—';
  const parts = [`${p.ds}:${p.signal}`, p.type];
  if (p.default !== undefined) parts.push(`default=${Array.isArray(p.default) ? `[${p.default.length} pts]` : p.default}`);
  if (p.mandatory) parts.push('mandatory');
  if (p.min !== undefined) parts.push(`min=${p.min}`);
  if (p.max !== undefined) parts.push(`max=${p.max}`);
  return parts.join(', ');
}

function SetDiffTable({ entries }: { entries: SetDiffEntry[] }) {
  if (entries.length === 0) return <div class="hint">No parameters.</div>;
  return (
    <table class="cfg-diff-table">
      <thead><tr><th>Parameter</th><th>Left</th><th>Right</th></tr></thead>
      <tbody>
        {entries.map(e => (
          <tr key={e.key} class={`cfg-diff-${e.status}`}>
            <td>{e.key} <span class="cfg-diff-status">{e.status}</span></td>
            <td>{paramSummary(e.left)}</td>
            <td>{paramSummary(e.right)}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function InstanceDiffTable({ entries }: { entries: InstanceDiffEntry[] }) {
  if (entries.length === 0) return <div class="hint">No values.</div>;
  return (
    <table class="cfg-diff-table">
      <thead><tr><th>Parameter</th><th>Left</th><th>Right</th></tr></thead>
      <tbody>
        {entries.map(e => (
          <tr key={e.key} class={`cfg-diff-${e.status}`}>
            <td>{e.key} <span class="cfg-diff-status">{e.status}</span></td>
            <td>{e.left !== undefined ? String(e.left) : '—'}</td>
            <td>{e.right !== undefined ? String(e.right) : '—'}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

// ── Rules manager (CUE validation/transformation) ──────────────────────────────

// sampleValues builds a representative value map from a set's parameter
// defaults, used to live-evaluate a rule's constraints while editing.
function sampleValues(set: ConfigSet | null): Record<string, any> {
  const out: Record<string, any> = {};
  if (!set) return out;
  for (const p of set.parameters) {
    if (p.default !== undefined && p.default !== null) out[p.key] = p.default;
  }
  return out;
}

function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
  const [rules, setRules] = useState<Meta[]>([]);
  const [sets, setSets] = useState<Meta[]>([]);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const hist = useUndo<ConfigRule>();
  const working = hist.present;
  const [boundSet, setBoundSet] = useState<ConfigSet | null>(null);
  const [dirty, setDirty] = useState(false);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [check, setCheck] = useState<RuleResult | null>(null);
  const [setFilter, setSetFilter] = useState<string>('');
  const [preview, setPreview] = useState<RulePreview | null>(null);
  const [showNew, setShowNew] = useState(false);
  const [viewingVersion, setViewingVersion] = useState<number | null>(null);
  const [verReload, setVerReload] = useState(0);
  const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
  const { pending, setPending } = useCloseGuard(registerGuard, dirty);

  function undo() { hist.undo(); setDirty(true); }
  function redo() { hist.redo(); setDirty(true); }
  useUndoKeys(undo, redo, hist.canUndo, hist.canRedo);

  const reload = useCallback(async () => {
    try {
      setRules((await apiJSON<Meta[]>('/api/v1/config/rules')) ?? []);
      setSets((await apiJSON<Meta[]>('/api/v1/config/sets')) ?? []);
    } catch (err) { setError(`Failed to load: ${msg(err)}`); }
  }, []);
  useEffect(() => { reload(); }, [reload]);

  async function loadSet(setId: string): Promise<ConfigSet | null> {
    if (!setId) return null;
    return apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(setId)}`);
  }

  async function runPreview() {
    if (!working) return;
    setBusy(true); setError(null);
    try {
      const res = await apiJSON<RulePreview>('/api/v1/config/rules/preview',
        jsonPost({ setId: working.setId, source: working.source }));
      setPreview(res);
    } catch (err) { setError(`Preview failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  async function select(id: string) {
    if (dirty && !confirm('Discard unsaved changes?')) return;
    setError(null); setCheck(null); setPreview(null);
    try {
      const rule = await apiJSON<ConfigRule>(`/api/v1/config/rules/${encodeURIComponent(id)}`);
      const set = await loadSet(rule!.setId);
      setSelectedId(id);
      hist.load(rule);
      setBoundSet(set);
      setDirty(false);
    } catch (err) { setError(msg(err)); }
  }

  async function createRule(name: string, setId: string) {
    setBusy(true); setError(null);
    try {
      const body = { name, setId, enabled: true, source: '// CUE rule: constrain or derive parameter values.\n' };
      const created = await apiJSON<ConfigRule>('/api/v1/config/rules', jsonPost(body));
      const set = await loadSet(created!.setId);
      await reload();
      setSelectedId(created!.id);
      hist.load(created);
      setBoundSet(set);
      setDirty(false);
      setShowNew(false);
    } catch (err) { setError(`Create failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  async function save(): Promise<boolean> {
    if (!working) return false;
    setBusy(true); setError(null);
    try {
      const saved = await apiJSON<ConfigRule>(`/api/v1/config/rules/${encodeURIComponent(working.id)}`, jsonPut(working));
      hist.load(saved);
      setDirty(false);
      setViewingVersion(null);
      setVerReload(k => k + 1);
      await reload();
      return true;
    } catch (err) { setError(`Save failed: ${msg(err)}`); return false; }
    finally { setBusy(false); }
  }

  async function remove(id: string) {
    if (!confirm('Delete this rule? A server-side copy is kept in trash.')) return;
    setBusy(true); setError(null);
    try {
      await apiJSON(`/api/v1/config/rules/${encodeURIComponent(id)}`, { method: 'DELETE' });
      if (selectedId === id) { setSelectedId(null); hist.load(null); setBoundSet(null); setDirty(false); }
      await reload();
    } catch (err) { setError(`Delete failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  function patch(p: Partial<ConfigRule>) {
    if (!working) return;
    hist.commit({ ...working, ...p });
    setDirty(true);
  }

  // Live-validate the working source against the bound set's sample values. The
  // /check endpoint compiles the (unsaved) source and reports compile errors and
  // constraint violations, debounced so each keystroke does not hit the server.
  useEffect(() => {
    if (!working) { setCheck(null); return; }
    const handle = setTimeout(async () => {
      try {
        const res = await apiJSON<RuleResult>('/api/v1/config/rules/check',
          jsonPost({ source: working.source, values: sampleValues(boundSet) }));
        setCheck(res);
      } catch { /* transient; ignore */ }
    }, 400);
    return () => clearTimeout(handle);
  }, [working?.source, boundSet]);

  async function viewVersion(version: number) {
    if (!working) return;
    if (dirty && !confirm('Discard unsaved changes to view this version?')) return;
    setError(null);
    try {
      const rule = await apiJSON<ConfigRule>(`/api/v1/config/rules/${encodeURIComponent(working.id)}/versions/${version}`);
      hist.load(rule);
      setViewingVersion(version);
      setDirty(false);
    } catch (err) { setError(`Load version failed: ${msg(err)}`); }
  }
  async function reloadCurrent() {
    if (!working) return;
    try {
      const rule = await apiJSON<ConfigRule>(`/api/v1/config/rules/${encodeURIComponent(working.id)}`);
      hist.load(rule);
      setViewingVersion(null);
      setDirty(false);
      setVerReload(k => k + 1);
      await reload();
    } catch (err) { setError(msg(err)); }
  }

  const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
  // Parameter keys + target signals offered as CUE completions.
  const completions = boundSet
    ? boundSet.parameters.flatMap(p => [p.key, `${p.ds}:${p.signal}`]).filter(Boolean)
    : [];
  const visibleRules = rules.filter(s => !setFilter || s.setId === setFilter);

  return (
    <div class="cl-body">
      <div class="cl-list">
        <div class="cl-list-head">
          <span>Rules</span>
          <button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
        </div>
        {sets.length > 1 && (
          <div class="cl-list-filter">
            <select class="prop-select" value={setFilter} title="Filter rules by config set"
              onChange={(e) => setSetFilter((e.target as HTMLSelectElement).value)}>
              <option value="">All sets</option>
              {sets.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
            </select>
          </div>
        )}
        {sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
        {sets.length > 0 && rules.length === 0 && <div class="hint cl-list-empty">No rules yet.</div>}
        {sets.length > 0 && rules.length > 0 && visibleRules.length === 0 && <div class="hint cl-list-empty">No rules in this set.</div>}
        {visibleRules.map(s => (
          <div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}${ruleEnabled(s) ? '' : ' cfg-rule-disabled'}`} onClick={() => select(s.id)}>
            <span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
            {!ruleEnabled(s) && <span class="hint cfg-rule-off" title="Disabled — not run on save/apply">off</span>}
            {s.setId && <span class="hint cfg-rule-set" title="Bound set">{setName(s.setId)}</span>}
            <span class="cfg-badge">v{s.version}</span>
            <button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}>✕</button>
          </div>
        ))}
      </div>

      <div class="cl-editor-wrap">
        {error && <div class="cl-error">{error}</div>}
        {!working && <div class="cl-empty hint">Select a rule on the left, or create a new one. Rules run when instances of the bound set are saved.</div>}
        {working && (
          <div class="cfg-editor">
            <div class="cfg-edit-bar">
              <input class="prop-input cl-name-input" value={working.name} placeholder="Rule name"
                onInput={(e) => patch({ name: (e.target as HTMLInputElement).value })} />
              <span class="cfg-badge">v{working.version}</span>
              {dirty && <span class="cl-dirty">unsaved</span>}
              <button class="panel-btn" disabled={!hist.canUndo} title="Undo (Ctrl+Z)" onClick={undo}>↩</button>
              <button class="panel-btn" disabled={!hist.canRedo} title="Redo (Ctrl+Shift+Z)" onClick={redo}>↪</button>
              <button class="panel-btn" disabled={busy} title="Run this rule against a live snapshot of the set's signals (nothing is stored)" onClick={runPreview}>⚡ Preview</button>
              <button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
            </div>

            <div class="cfg-param-row">
              <div class="cfg-field cfg-field-target">
                <label>Bound set</label>
                <select class="prop-select" value={working.setId}
                  onChange={async (e) => { const id = (e.target as HTMLSelectElement).value; patch({ setId: id }); setBoundSet(await loadSet(id)); }}>
                  {sets.map(s => <option key={s.id} value={s.id}>{s.name} (v{s.version})</option>)}
                </select>
              </div>
              <div class="cfg-field cfg-field-label">
                <label>Description</label>
                <input class="prop-input" value={working.description ?? ''} placeholder="(optional)"
                  onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />
              </div>
              <div class="cfg-field cfg-field-enabled">
                <label>Enabled</label>
                <label class="cfg-rule-enable" title="When enabled, this rule runs every time an instance of the bound set is saved or applied">
                  <input type="checkbox" checked={ruleEnabled(working)}
                    onChange={(e) => patch({ enabled: (e.target as HTMLInputElement).checked })} />
                  <span>{ruleEnabled(working) ? 'Runs on save/apply' : 'Disabled (not run)'}</span>
                </label>
              </div>
            </div>

            <div class="cfg-section-head"><span>CUE source</span></div>
            <div class="hint cfg-rule-help">
              Regular fields whose key matches a parameter are unified with the instance value — constraints
              (e.g. <code>voltage: &gt;=0 &amp; &lt;=24</code>) validate it, and concrete derivations
              (e.g. <code>power: voltage * current</code>) transform it. Use <code>_helpers</code> and
              <code>#Defs</code> for intermediate values. Ctrl+Space for completions.
            </div>
            <CueEditor value={working.source} rows={14} completions={completions}
              onChange={(v) => patch({ source: v })} />

            <RuleCheckView result={check} />

            {preview && <RulePreviewView preview={preview} onClose={() => setPreview(null)} />}

            <VersionPanel kind="rules" id={working.id}
              viewing={viewingVersion} dirty={dirty} reloadKey={verReload}
              onView={viewVersion}
              onForked={(id) => { reload(); select(id); }}
              onPromoted={reloadCurrent}
              onCompare={(av, bv) => setDiff({ av, bv })} onError={setError} />
          </div>
        )}
      </div>

      {showNew && <NewRuleDialog sets={sets} busy={busy} onCancel={() => setShowNew(false)} onCreate={createRule} />}

      {diff && working && (
        <DiffViewer base={`/api/v1/config/rules/${encodeURIComponent(working.id)}`}
          av={diff.av} bv={diff.bv} title={`${working.name}: v${diff.av} → v${diff.bv}`}
          onClose={() => setDiff(null)} />
      )}

      {pending && (
        <UnsavedDialog busy={busy}
          onCancel={() => setPending(null)}
          onDiscard={() => { const p = pending.proceed; setDirty(false); setPending(null); p(); }}
          onSave={async () => { const ok = await save(); if (ok) { const p = pending.proceed; setPending(null); p(); } }} />
      )}
    </div>
  );
}

// RuleCheckView renders the live evaluation outcome: compile errors, constraint
// violations, or a pass with any transformed sample values.
function RuleCheckView({ result }: { result: RuleResult | null }) {
  if (!result) return null;
  if (result.compileError) {
    return <div class="cfg-rule-check cfg-rule-check-err">
      <b>Compile error:</b> {result.compileError}
    </div>;
  }
  if (!result.ok) {
    return (
      <div class="cfg-rule-check cfg-rule-check-err">
        <b>{(result.violations ?? []).length} violation(s)</b> against sample (default) values:
        <ul class="cfg-rule-violations">
          {(result.violations ?? []).map((v, i) => (
            <li key={i}>{v.path ? <code>{v.path}</code> : null} {v.message}</li>
          ))}
        </ul>
      </div>
    );
  }
  const transformed = result.transformed ? Object.entries(result.transformed) : [];
  return (
    <div class="cfg-rule-check cfg-rule-check-ok">
      <b>✓ Valid</b> — sample (default) values pass.
      {transformed.length > 0 && (
        <Fragment>
          {' '}Derived: {transformed.map(([k, v]) => <span key={k} class="cfg-rule-derived"><code>{k}</code>={String(v)}</span>)}
        </Fragment>
      )}
    </div>
  );
}

// RulePreviewView shows the outcome of running a rule against a live snapshot of
// the bound set's signals: the captured input values on the left and the
// processed output (input merged with rule-derived values) on the right, plus
// any compile error / violations. Nothing is persisted.
function RulePreviewView({ preview, onClose }: { preview: RulePreview; onClose: () => void }) {
  const input: Record<string, any> = {};
  for (const e of preview.snapshot.entries) if (e.ok) input[e.key] = e.value;
  const output = { ...input, ...(preview.result.transformed ?? {}) };
  const failed = preview.snapshot.entries.filter(e => !e.ok);
  return (
    <div class="cfg-rule-preview">
      <div class="cfg-rule-preview-head">
        <b>Live preview</b>
        <span class="hint">{preview.snapshot.captured} captured · {preview.snapshot.failed} failed</span>
        <button class="cl-mini-btn" title="Close preview" onClick={onClose}>✕</button>
      </div>
      {preview.result.compileError && (
        <div class="cfg-rule-check cfg-rule-check-err"><b>Compile error:</b> {preview.result.compileError}</div>
      )}
      {!preview.result.compileError && !preview.result.ok && (
        <div class="cfg-rule-check cfg-rule-check-err">
          <b>{(preview.result.violations ?? []).length} violation(s):</b>
          <ul class="cfg-rule-violations">
            {(preview.result.violations ?? []).map((v, i) => (
              <li key={i}>{v.path ? <code>{v.path}</code> : null} {v.message}</li>
            ))}
          </ul>
        </div>
      )}
      {failed.length > 0 && (
        <div class="hint cfg-rule-preview-failed">
          Could not read: {failed.map(e => `${e.key} (${e.error})`).join(', ')}
        </div>
      )}
      <div class="cfg-rule-preview-cols">
        <div class="cfg-rule-preview-col">
          <div class="hint">Snapshot input</div>
          <pre class="cfg-json">{JSON.stringify(input, null, 2)}</pre>
        </div>
        <div class="cfg-rule-preview-col">
          <div class="hint">Processed output{preview.result.ok ? '' : ' (would be rejected)'}</div>
          <pre class="cfg-json">{JSON.stringify(output, null, 2)}</pre>
        </div>
      </div>
    </div>
  );
}

function NewRuleDialog({ sets, busy, onCancel, onCreate }: {
  sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void;
}) {
  const [name, setName] = useState('New rule');
  const [setId, setSetId] = useState(sets[0]?.id ?? '');
  return (
    <div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
      <div class="cl-confirm">
        <div class="cl-confirm-title">New config rule</div>
        <div class="cfg-field">
          <label>Name</label>
          <input class="prop-input" value={name} autoFocus onInput={(e) => setName((e.target as HTMLInputElement).value)} />
        </div>
        <div class="cfg-field">
          <label>Config set</label>
          <select class="prop-select" value={setId} onChange={(e) => setSetId((e.target as HTMLSelectElement).value)}>
            {sets.map(s => <option key={s.id} value={s.id}>{s.name} (v{s.version})</option>)}
          </select>
        </div>
        <div class="cl-confirm-actions">
          <button class="panel-btn" onClick={onCancel}>Cancel</button>
          <button class="panel-btn panel-btn-primary" disabled={busy || !setId || !name.trim()}
            onClick={() => onCreate(name.trim(), setId)}>Create</button>
        </div>
      </div>
    </div>
  );
}

// ── small helpers ─────────────────────────────────────────────────────────────

function msg(err: unknown): string { return err instanceof Error ? err.message : String(err); }
function jsonPost(body: any): RequestInit { return { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; }
function jsonPut(body: any): RequestInit { return { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; }
function numOrUndef(v: string): number | undefined { if (v.trim() === '') return undefined; const n = Number(v); return isNaN(n) ? undefined : n; }
function coerceVal(v: string, type: ParamType): any {
  if (type === 'float64' || type === 'int64') { const n = Number(v); return isNaN(n) ? v : n; }
  if (type === 'float64[]') return parseArray(v);
  return v;
}

// parseArray turns a comma/space/semicolon/newline-separated string into a
// number[]. Returns undefined for an empty input (so it falls back to default).
function parseArray(v: string): number[] | undefined {
  const nums = v.split(/[\s,;]+/).map(s => s.trim()).filter(Boolean).map(Number).filter(n => !isNaN(n));
  return nums.length ? nums : undefined;
}

function fmtNum(n: number): string { return Number.isInteger(n) ? String(n) : n.toFixed(3); }
