import { h, Fragment } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
import SignalPicker, { SignalOption } from './SignalPicker';

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

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

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;
  parameters: Parameter[];
}

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

interface Meta { id: string; name: string; version: number; }
interface VersionMeta { version: number; name: string; tag?: string; current: boolean; savedAt: string; }

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 PARAM_TYPES: ParamType[] = ['float64', 'int64', 'bool', 'string', 'enum'];

// ── 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, string[]>>({});

  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: { name: string }[]) => setDsSignals(p => ({ ...p, [ds]: sigs.map(s => s.name) })))
        .catch(() => {});
      return prev;
    });
  }, []);

  function allOptions(): SignalOption[] {
    const out: SignalOption[] = [];
    for (const ds of dataSources) for (const name of dsSignals[ds] ?? []) out.push({ ds, name });
    return out;
  }
  function openAll() { dataSources.forEach(loadSignals); }

  return { allOptions, openAll };
}

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) };
}

function fmtTime(iso: string): string {
  const d = new Date(iso);
  if (isNaN(d.getTime())) return iso;
  const pad = (n: number) => String(n).padStart(2, '0');
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

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

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

export default function ConfigManager({ onClose }: Props) {
  const [tab, setTab] = useState<'sets' | 'instances'>('sets');

  return (
    <div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div class="cl-modal">
        <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={onClose}>Close</button>
          </div>
        </header>

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

        {tab === 'sets' ? <SetsManager /> : <InstancesManager />}
      </div>
    </div>
  );
}

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

function SetsManager() {
  const [sets, setSets] = useState<Meta[]>([]);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [working, setWorking] = useState<ConfigSet | null>(null);
  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 { allOptions, openAll } = useSignals();

  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);
      setWorking(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);
      setWorking(created);
      setDirty(false);
    } catch (err) { setError(`Create failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  async function save() {
    if (!working) return;
    setBusy(true); setError(null);
    try {
      const saved = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(working.id)}`, jsonPut(working));
      setWorking(saved);
      setDirty(false);
      await reload();
    } catch (err) { setError(`Save failed: ${msg(err)}`); }
    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); setWorking(null); setDirty(false); }
      await reload();
    } catch (err) { setError(`Delete failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  function patch(p: Partial<ConfigSet>) {
    if (!working) return;
    setWorking({ ...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 addParam() {
    if (!working) return;
    const n = working.parameters.length + 1;
    patch({ parameters: [...working.parameters, { key: `param${n}`, ds: '', signal: '', type: 'float64' }] });
  }
  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)}`); }
  }

  return (
    <div class="cl-body">
      <div class="cl-list">
        <div class="cl-list-head">
          <span>Config sets</span>
          <button class="panel-btn" disabled={busy} onClick={createSet}>+ New</button>
        </div>
        {sets.length === 0 && <div class="hint cl-list-empty">No config sets yet.</div>}
        {sets.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 panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
            </div>
            <input class="prop-input" value={working.description ?? ''} placeholder="Description (optional)"
              onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />

            <div class="cfg-section-head">
              <span>Parameters</span>
              <button class="panel-btn" onClick={addParam}>+ Add parameter</button>
            </div>
            {working.parameters.length === 0 && <div class="hint">No parameters. Each parameter binds a target signal with a default and constraints.</div>}
            <div class="cfg-params">
              {working.parameters.map((p, i) => (
                <ParamEditor key={i} param={p} allOptions={allOptions()} onOpenSignals={openAll}
                  onChange={(patch2) => patchParam(i, patch2)} onRemove={() => removeParam(i)} />
              ))}
            </div>

            <VersionPanel kind="sets" id={working.id} onReload={reload}
              onForked={(id) => { reload(); select(id); }}
              onPromoted={() => select(working.id)}
              onCompare={showDiff} onError={setError} />
          </div>
        )}
      </div>

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

function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
  param: Parameter;
  allOptions: SignalOption[];
  onOpenSignals: () => void;
  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';
  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>
          <select class="prop-select" value={param.type} onChange={(e) => onChange({ type: (e.target as HTMLSelectElement).value as ParamType })}>
            {PARAM_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
          </select>
        </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={(r) => { const s = splitRef(r); onChange({ ds: s.ds, signal: s.signal }); }} />
        </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>
          ) : (
            <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>Group</label>
          <input class="prop-input" value={param.group ?? ''} placeholder="(optional)"
            onInput={(e) => onChange({ group: (e.target as HTMLInputElement).value })} />
        </div>
        <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>
        {isNum && (
          <Fragment>
            <div class="cfg-field cfg-field-num">
              <label>Min</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() {
  const [instances, setInstances] = useState<Meta[]>([]);
  const [sets, setSets] = useState<Meta[]>([]);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [working, setWorking] = useState<ConfigInstance | null>(null);
  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 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);
      setWorking(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);
      setWorking(created);
      setBoundSet(set);
      setDirty(false);
      setShowNew(false);
    } catch (err) { setError(`Create failed: ${msg(err)}`); }
    finally { setBusy(false); }
  }

  async function save() {
    if (!working) return;
    setBusy(true); setError(null);
    try {
      const saved = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(working.id)}`, jsonPut(working));
      setWorking(saved);
      setDirty(false);
      await reload();
    } catch (err) { setError(`Save failed: ${msg(err)}`); }
    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); setWorking(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;
    setWorking({ ...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)}`); }
  }

  const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;

  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} onClick={() => setShowNew(true)}>+ New</button>
        </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>}
        {instances.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) => { setWorking({ ...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 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>
            </div>
            <div class="hint">Based on set <b>{setName(working.setId)}</b>{working.setVersion ? ` (v${working.setVersion})` : ''}.</div>

            {!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} onReload={reload}
              onForked={(id) => { reload(); select(id); }}
              onPromoted={() => select(working.id)}
              onCompare={showDiff} onError={setError} />
          </div>
        )}
      </div>

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

      {diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
        <InstanceDiffTable entries={diff.entries} />
      </DiffModal>}
    </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 defText = param.default !== undefined && param.default !== null ? String(param.default) : '';
  return (
    <div class="cfg-value-row">
      <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>}
      </div>
      <div class="cfg-value-input">
        {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>
        ) : (
          <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>
  );
}

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 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, onForked, onPromoted, onCompare, onError }: {
  kind: 'sets' | 'instances';
  id: string;
  onReload: () => void;
  onForked: (newId: string) => void;
  onPromoted: () => void;
  onCompare: (av: number, bv: number) => void;
  onError: (m: string) => void;
}) {
  const [versions, setVersions] = useState<VersionMeta[]>([]);
  const [open, setOpen] = useState(false);
  const [a, setA] = useState<number | null>(null);
  const [b, setB] = useState<number | null>(null);

  const base = `/api/v1/config/${kind}`;

  const load = useCallback(async () => {
    try {
      const v = (await apiJSON<VersionMeta[]>(`${base}/${encodeURIComponent(id)}/versions`)) ?? [];
      setVersions(v);
      if (v.length >= 2) { setA(v[1].version); setB(v[0].version); }
      else if (v.length === 1) { setA(v[0].version); setB(v[0].version); }
    } catch (err) { onError(`Versions failed: ${msg(err)}`); }
  }, [base, id, onError]);

  useEffect(() => { if (open) load(); }, [open, load]);
  // Reset when switching object.
  useEffect(() => { setOpen(false); setVersions([]); }, [id]);

  async function fork(version: number) {
    try {
      const obj = await apiJSON<{ id: string }>(`${base}/${encodeURIComponent(id)}/versions/${version}/fork`, { method: 'POST' });
      onForked(obj!.id);
    } catch (err) { onError(`Fork failed: ${msg(err)}`); }
  }
  async function promote(version: number) {
    if (!confirm(`Promote v${version} to a new current version?`)) return;
    try {
      await apiJSON(`${base}/${encodeURIComponent(id)}/versions/${version}/promote`, { method: 'POST' });
      onPromoted();
    } catch (err) { onError(`Promote failed: ${msg(err)}`); }
  }

  return (
    <div class="cfg-versions">
      <button class="cfg-versions-toggle" onClick={() => setOpen(o => !o)}>
        {open ? '▾' : '▸'} Version history
      </button>
      {open && (
        <div class="cfg-versions-body">
          {versions.length === 0 && <div class="hint">No versions.</div>}
          {versions.map(v => (
            <div key={v.version} class={`cfg-version-row${v.current ? ' cfg-version-current' : ''}`}>
              <span class="cfg-version-num">v{v.version}{v.current && <span class="cfg-badge">current</span>}</span>
              <span class="cfg-version-tag hint" title={v.tag}>{v.tag || ''}</span>
              <span class="cfg-version-time hint">{fmtTime(v.savedAt)}</span>
              <button class="cl-mini-btn" title="Fork into a new object" onClick={() => fork(v.version)}>fork</button>
              {!v.current && <button class="cl-mini-btn" title="Promote to current" onClick={() => promote(v.version)}>promote</button>}
            </div>
          ))}
          {versions.length >= 2 && (
            <div class="cfg-compare">
              <span class="hint">Compare</span>
              <select class="prop-select" value={String(a ?? '')} onChange={(e) => setA(Number((e.target as HTMLSelectElement).value))}>
                {versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
              </select>
              <span>→</span>
              <select class="prop-select" value={String(b ?? '')} onChange={(e) => setB(Number((e.target as HTMLSelectElement).value))}>
                {versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
              </select>
              <button class="panel-btn" disabled={a === null || b === null} onClick={() => onCompare(a!, b!)}>Diff</button>
            </div>
          )}
        </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=${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>
  );
}

// ── 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; }
  return v;
}
