import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
import { RotorCompass } from '@/components/RotorCompass';
import { AmpCard } from '@/components/AmpCard';
import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel';
import {
  GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
  GetRotatorHeading, RotatorGoTo, RotatorStop,
  GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
  ListDenkoviDevices, ListSerialPorts, TestStationDevice,
  GetAmpStatuses, GetFlexState,
  GetTunerGeniusStatus, GetTunerGeniusSettings,
} from '../../wailsjs/go/main/App';

type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };

type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; channels?: number; labels: string[] };
type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };

const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };

// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
// the generic USB-serial board, whose channel count the user picks.
const chanCount = (d: Device): number =>
  (d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);

function blankDevice(): Device {
  return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
}

type Heading = { enabled: boolean; ok: boolean; azimuth: number };

// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
// Station Control tab: a live compass you can click to turn, a heading readout, a
// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and
// passed in (so the panel can also order this widget); it drives the shared
// rotator backend.
function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & {
  hd: Heading; refetch: () => void; t: (k: string, v?: any) => string;
}) {
  const [goto, setGoto] = useState('');
  const [err, setErr] = useState('');

  const turn = (az: number) => {
    const a = ((Math.round(az) % 360) + 360) % 360;
    RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e)));
  };
  const poll = refetch;

  const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]];
  return (
    <div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
      <div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
        <Compass className="size-4 text-primary" />
        <div className="text-sm font-semibold">{t('station.rotator')}</div>
        <span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
          title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
      </div>
      <div className="p-3 flex gap-4 items-start">
        <RotorCompass
          bearing={bearing ?? null}
          headings={hd.ok ? [hd.azimuth] : []}
          centerLat={centerLat ?? null}
          centerLon={centerLon ?? null}
          rotorEnabled={hd.ok}
          onGoto={(az) => turn(az)}
        />
        <div className="flex-1 min-w-0 space-y-2">
          <div className="font-mono">
            <span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
          </div>
          <div className="flex gap-1">
            {presets.map(([lbl, az]) => (
              <button key={lbl} type="button" onClick={() => turn(az)}
                className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
                {lbl}
              </button>
            ))}
          </div>
          <div className="flex gap-1.5">
            <Input value={goto} onChange={(e) => setGoto(e.target.value.replace(/[^0-9]/g, ''))}
              onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }}
              placeholder="0–359" className="h-8 flex-1 min-w-0 font-mono text-sm" />
            <Button size="sm" className="h-8 shrink-0" disabled={!goto} onClick={() => turn(parseInt(goto, 10))}>{t('station.go')}</Button>
          </div>
          <Button size="sm" variant="outline" className="h-8 w-full" onClick={() => RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
            <Square className="size-3 mr-1" /> {t('station.stop')}
          </Button>
          {err && <div className="text-[11px] text-destructive break-words">{err}</div>}
        </div>
      </div>
    </div>
  );
}

type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[] };

// MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the
// Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam
// only — per-element length adjustment. Heading/state is polled by the panel.
const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press

// elementName maps an element index to a ham-radio name: 0 = reflector,
// 1 = driven element, then Director 1, 2, 3…
function elementName(i: number, t: (k: string, v?: any) => string): string {
  if (i === 0) return t('station.reflector');
  if (i === 1) return t('station.driven');
  return `${t('station.director')} ${i - 1}`;
}

function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string }) {
  const [err, setErr] = useState('');
  const [lengths, setLengths] = useState<number[]>([]); // current element lengths (mm), from ReadElements
  const [reading, setReading] = useState(false);
  const [busyEl, setBusyEl] = useState<number | null>(null);
  const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
  const [editVal, setEditVal] = useState('');
  const run = (p: Promise<any>) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e)));
  const isUB = ant.type !== 'steppir';

  const readLengths = useCallback(async () => {
    setReading(true); setErr('');
    try { setLengths(((await MotorReadElements()) ?? []) as number[]); }
    catch (e: any) { setErr(String(e?.message ?? e)); }
    finally { setReading(false); }
  }, []);
  // Read the current lengths once when the Ultrabeam widget mounts/connects, so
  // +/- starts from the real values rather than blind.
  useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]);

  // Send an absolute length to one element and remember it as the new baseline.
  const setLen = async (i: number, mm: number) => {
    const next = Math.max(0, Math.round(mm));
    const prev = lengths[i] ?? 0;
    setBusyEl(i); setErr('');
    setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic
    try { await MotorSetElement(i, next); refetch(); }
    catch (e: any) {
      // Rejected by the controller — most often the element is at its travel
      // limit for this band (extending on a low band). Revert the optimistic
      // value so the display stays truthful, and explain the likely cause when
      // the failed move was an extension.
      setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; });
      setErr(next > prev ? t('station.atMax') : String(e?.message ?? e));
    }
    finally { setBusyEl(null); }
  };
  // Nudge one element by ±2 mm from its current known length.
  const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta);
  // Commit a typed exact length (click on the mm value). Lets the operator fix
  // the baseline when the auto-read is off, so +/- then work reliably.
  const commitEdit = (i: number) => {
    const v = parseInt(editVal, 10);
    setEditingEl(null);
    if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v);
  };
  const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]];
  return (
    <div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
      <div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
        <AntennaIcon className="size-4 text-primary" />
        <div className="min-w-0">
          <div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
          {ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
        </div>
        {ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
        <span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
          title={ant.connected ? t('station.online') : t('station.offline')} />
      </div>
      <div className="p-3 space-y-3">
        <div>
          <div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.pattern')}</div>
          <div className="flex gap-1">
            {dirs.map(([d, lbl]) => (
              <button key={d} type="button" disabled={!ant.connected}
                onClick={() => run(SetUltrabeamDirection(d))}
                className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors disabled:opacity-40',
                  ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
                {lbl}
              </button>
            ))}
          </div>
        </div>
        <button type="button" disabled={!ant.connected}
          onClick={() => run(UltrabeamRetract())}
          className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
          <ArrowDownToLine className="size-3.5" /> {t('station.retract')}
        </button>

        {isUB && (
          <div>
            <div className="flex items-center gap-2 mb-1.5">
              <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
              <button type="button" onClick={readLengths} disabled={!ant.connected || reading}
                className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
                <RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
              </button>
            </div>
            {lengths.length === 0 ? (
              <p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
            ) : (
              <div className="space-y-1.5">
                {/* The READ_BANDS reply is undocumented and its 16-bit parse picks
                    up structural bytes past the real data (varying by band), which
                    made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
                    and ModifyElement addresses elements 0..2 — so show just those. */}
                {lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
                  <div key={i} className="flex items-center gap-2">
                    <span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
                    <button type="button" disabled={!ant.connected || busyEl !== null}
                      onClick={() => nudge(i, -ELEMENT_STEP)}
                      className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
                      <Minus className="size-3.5" />
                    </button>
                    {editingEl === i ? (
                      <input autoFocus type="number" value={editVal}
                        onChange={(e) => setEditVal(e.target.value)}
                        onBlur={() => commitEdit(i)}
                        onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }}
                        className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" />
                    ) : (
                      <span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
                        title={t('station.setExactLen')}
                        onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
                        {busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
                      </span>
                    )}
                    <button type="button" disabled={!ant.connected || busyEl !== null}
                      onClick={() => nudge(i, ELEMENT_STEP)}
                      className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
                      <Plus className="size-3.5" />
                    </button>
                  </div>
                ))}
              </div>
            )}
            <p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
          </div>
        )}
        {err && <div className="text-[11px] text-destructive break-words">{err}</div>}
      </div>
    </div>
  );
}

export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
  const { t } = useI18n();
  const [devices, setDevices] = useState<Device[]>([]);
  const [status, setStatus] = useState<Record<string, DevStatus>>({});
  const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
  const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
  const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
  const [ant, setAnt] = useState<AntStatus>({ enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [] });
  // Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
  const [order, setOrder] = useState<string[]>(() => {
    try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
  });
  const dragId = useRef<string | null>(null);
  // Max columns per row (persisted). "Auto" fills the window; a number caps the
  // row so cards wrap onto further lines even when there's horizontal room.
  const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
  // Amplifiers (Settings → Amplifier): shown here so operators without a
  // FlexRadio panel still get the controls. EVERY configured amp gets its own
  // card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
  // FlexRadio meters read live; the Flex state rides along because a PGXL's
  // OPERATE/meters come from the radio, not the direct GSCP link.
  const [amps, setAmps] = useState<any[]>([]);
  const [flexState, setFlexState] = useState<any>(null);
  useEffect(() => {
    let alive = true;
    const load = () => Promise.all([
      GetAmpStatuses().catch(() => []),
      GetFlexState().catch(() => null),
    ]).then(([l, fx]: any[]) => { if (alive) { setAmps((l ?? []) as any[]); setFlexState(fx); } });
    load();
    const id = window.setInterval(load, 1500);
    return () => { alive = false; window.clearInterval(id); };
  }, []);
  // Tuner Genius XL (4O3A): a card here too, so operators without a FlexRadio
  // panel still get the controls. Re-read the enabled flag so it appears/hides
  // without a restart.
  const [tg, setTg] = useState<TGStatus>({ connected: false });
  const [tgEnabled, setTgEnabled] = useState(false);
  useEffect(() => {
    let alive = true;
    const load = async () => {
      try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
      try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
    };
    load();
    const id = window.setInterval(load, 500); // fast so meters track TX (see App.tsx)
    return () => { alive = false; window.clearInterval(id); };
  }, []);

  const loadDevices = useCallback(async () => {
    try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
  }, []);

  const poll = useCallback(async () => {
    try {
      const s = ((await GetStationStatus()) ?? []) as DevStatus[];
      setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
    } catch { /* ignore transient */ }
  }, []);
  const pollRot = useCallback(async () => {
    try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
  }, []);
  const pollAnt = useCallback(async () => {
    try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
  }, []);

  useEffect(() => { loadDevices(); }, [loadDevices]);
  useEffect(() => {
    poll(); pollRot(); pollAnt();
    const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
    return () => window.clearInterval(id);
  }, [poll, pollRot, pollAnt, devices.length]);

  const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
  // Reorder so `dragged` lands just before `target`.
  const onDrop = (targetId: string) => {
    const src = dragId.current; dragId.current = null;
    if (!src || src === targetId) return;
    const ids = widgetIds.filter((id) => id !== src);
    const at = ids.indexOf(targetId);
    ids.splice(at < 0 ? ids.length : at, 0, src);
    persistOrder(ids);
  };

  const persist = async (next: Device[]) => {
    setDevices(next);
    try { await SaveStationDevices(next as any); } catch { /* surfaced by status */ }
    await loadDevices();
    poll();
  };

  const toggle = async (dev: Device, relay: number, on: boolean) => {
    const key = `${dev.id}:${relay}`;
    setBusy((b) => ({ ...b, [key]: true }));
    // Optimistic flip so the switch feels instant; the poll reconciles.
    setStatus((st) => {
      const d = st[dev.id]; if (!d) return st;
      return { ...st, [dev.id]: { ...d, relays: d.relays.map((r) => (r.number === relay ? { ...r, on } : r)) } };
    });
    try { await StationSetRelay(dev.id, relay, on); } catch { /* poll will correct */ }
    await poll();
    setBusy((b) => ({ ...b, [key]: false }));
  };

  const saveEdit = async () => {
    if (!editing) return;
    const d = { ...editing, name: editing.name.trim() || TYPE_LABEL[editing.type], host: editing.host.trim() };
    const exists = devices.some((x) => x.id && x.id === d.id);
    await persist(exists ? devices.map((x) => (x.id === d.id ? d : x)) : [...devices, d]);
    setEditing(null);
  };

  const removeDevice = async (id: string) => { await persist(devices.filter((x) => x.id !== id)); };

  // Build the widget list (rotator first by default, then devices), then order it
  // by the saved drag order — unknown ids fall to the end in their natural order.
  const deviceCard = (dev: Device) => {
    const st = status[dev.id];
    const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
    return (
      <div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
        <div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
          <PlugZap className="size-4 text-primary" />
          <div className="min-w-0">
            <div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
            <div className="text-[10px] text-muted-foreground font-mono truncate">{TYPE_LABEL[dev.type]} · {dev.host}</div>
          </div>
          <span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
            title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
          <button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
            onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
          <button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
            onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
        </div>
        {/* Compact one-line relay buttons at a FIXED width so they don't stretch
            across the whole card — they wrap to fill the available width instead. */}
        <div className="p-2 flex flex-wrap gap-1.5">
          {relays.map((r) => {
            const key = `${dev.id}:${r.number}`;
            const label = r.label || `${t('station.relay')} ${r.number}`;
            return (
              <button key={r.number} type="button" disabled={!st?.connected}
                title={label}
                onClick={() => toggle(dev, r.number, !r.on)}
                className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
                  r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
                <span className={cn('flex items-center justify-center size-5 rounded shrink-0',
                  r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
                  {busy[key] ? <Loader2 className="size-3 animate-spin" /> : <Power className="size-3" />}
                </span>
                <span className="flex-1 min-w-0 text-xs font-medium truncate">{label}</span>
                <span className={cn('text-[9px] font-bold shrink-0', r.on ? 'text-success' : 'text-muted-foreground/50')}>
                  {r.on ? t('station.on') : t('station.off')}
                </span>
              </button>
            );
          })}
        </div>
      </div>
    );
  };

  // `wide` cards span the whole dashboard instead of sitting in one masonry
  // column. The amplifier and tuner carry meter rows and a channel selector that
  // are unreadable squeezed into a ~430px column — they are the same cards the
  // FlexRadio panel shows full-width, and they need that room here too.
  const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
  if (rot.enabled) {
    widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
  }
  if (ant.enabled) {
    widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
  }
  // One card per configured amplifier (identical to the Flex panel's card).
  for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
  // Tuner Genius XL card (identical to the Flex panel's).
  if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
  for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });

  const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
  const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
  const widgetIds = ordered.map((w) => w.id);

  const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0 && !tgEnabled;

  return (
    <div className="flex-1 min-h-0 overflow-auto p-4">
      <div className="flex items-center justify-between mb-3">
        <h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
        <div className="flex items-center gap-2">
          {/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
              card can sit on a further line even with horizontal room to spare. */}
          <div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
            {(['auto', '1', '2', '3', '4'] as const).map((c) => (
              <button key={c} type="button"
                onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
                className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
                {c === 'auto' ? t('station.colsAuto') : c}
              </button>
            ))}
          </div>
          <Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
            <Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
          </Button>
        </div>
      </div>

      {noDevices && !editing && (
        <div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
          {t('station.empty')}
        </div>
      )}

      {/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
          they pack tightly by height (no ragged gaps under short cards). "Auto"
          fits as many ~430px columns as the window allows; a fixed count caps the
          container width to that many columns. Each card has a grip handle (left
          rail) as the drag initiator (the body is full of buttons). */}
      <div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
        ...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
        {ordered.map((w) => (
          // column-span:all lifts a wide card out of the columns and across the
          // full container, while keeping it in document order — so drag-reorder
          // still works between wide and normal cards. Capped so it stays a card
          // and not a banner on an ultra-wide window.
          <div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
            style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
            onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
            onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
            <div draggable
              onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
              onDragEnd={() => { dragId.current = null; }}
              title={t('station.dragMove')}
              className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
                dragId.current === w.id && 'opacity-60')}>
              <GripVertical className="size-4" />
            </div>
            <div className="flex-1 min-w-0">{w.node}</div>
          </div>
        ))}
      </div>

      {!noDevices && (
        <p className="text-[11px] text-muted-foreground mt-2">{t('station.dragHint')}</p>
      )}

      {editing && (
        <DeviceEditor device={editing} onChange={setEditing} onSave={saveEdit} onCancel={() => setEditing(null)} t={t} />
      )}
    </div>
  );
}

function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
  device: Device; onChange: (d: Device) => void; onSave: () => void; onCancel: () => void; t: (k: string, v?: any) => string;
}) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
  const setType = (type: string) => {
    const channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
    const n = chanCount({ ...device, type, channels });
    const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
    onChange({ ...device, type, channels, labels });
  };
  const setChannels = (channels: number) => {
    const n = chanCount({ ...device, channels });
    const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
    onChange({ ...device, channels, labels });
  };
  const isKM = device.type === 'kmtronic';
  const isDenkovi = device.type === 'denkovi';
  const isUsbRelay = device.type === 'usbrelay';
  // COM ports for the generic USB-serial relay picker.
  const [serialPorts, setSerialPorts] = useState<string[]>([]);
  useEffect(() => {
    if (!isUsbRelay) return;
    ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
  }, [isUsbRelay]);
  // Detected FTDI serials for the Denkovi picker.
  const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
  const [detecting, setDetecting] = useState(false);
  const [detectMsg, setDetectMsg] = useState('');
  const detectDenkovi = async () => {
    setDetecting(true);
    setDetectMsg('');
    try {
      const list = ((await ListDenkoviDevices()) ?? []) as string[];
      setDenkoviSerials(list);
      // Auto-fill when there's exactly one and nothing chosen yet.
      if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
      setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length }));
    } catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); }
    finally { setDetecting(false); }
  };
  // Connection test result for the current device config.
  const [testing, setTesting] = useState(false);
  const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
  const testDevice = async () => {
    setTesting(true);
    setTestMsg(null);
    try {
      const r: any = await TestStationDevice(device as any);
      setTestMsg(r?.ok
        ? { ok: true, text: t('station.testOk', { n: r.relays }) }
        : { ok: false, text: r?.error || t('station.testFail') });
    } catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); }
    finally { setTesting(false); }
  };
  useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
  return (
    <div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
      <div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
      <div className="grid grid-cols-2 gap-3">
        <div className="space-y-1">
          <Label>{t('station.type')}</Label>
          <Select value={device.type} onValueChange={setType}>
            <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
            <SelectContent>
              <SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
              <SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
              <SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
              <SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
            </SelectContent>
          </Select>
        </div>
        <div className="space-y-1">
          <Label>{t('station.name')}</Label>
          <Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
        </div>
      </div>
      {(isDenkovi || isUsbRelay) && (
        <div className="space-y-1 max-w-[10rem]">
          <Label>{t('station.channels')}</Label>
          <Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
            <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
            <SelectContent>
              {(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
                <SelectItem key={n} value={String(n)}>{n}</SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
      )}
      {isDenkovi ? (
        <div className="space-y-1 max-w-md">
          <Label>{t('station.ftdiSerial')}</Label>
          <div className="flex items-center gap-2">
            <Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
              list="denkovi-serials"
              onChange={(e) => onChange({ ...device, host: e.target.value })} />
            <datalist id="denkovi-serials">
              {denkoviSerials.map((s) => <option key={s} value={s} />)}
            </datalist>
            <Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
              {detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
              {t('station.detect')}
            </Button>
          </div>
          <p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
          {detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
        </div>
      ) : isUsbRelay ? (
        <div className="space-y-1 max-w-md">
          <Label>{t('station.comPort')}</Label>
          <div className="flex items-center gap-2">
            <Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
              <SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
              <SelectContent>
                {serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
                {serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
              </SelectContent>
            </Select>
            <Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
              <RefreshCw className="size-3.5" />
            </Button>
          </div>
          <p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
        </div>
      ) : (
      <div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
        <div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
          <Label>{t('station.host')}</Label>
          <Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
        </div>
        {isKM && (
          <>
            <div className="space-y-1">
              <Label>{t('station.user')}</Label>
              <Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
            </div>
            <div className="space-y-1">
              <Label>{t('station.pass')}</Label>
              <Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
            </div>
          </>
        )}
      </div>
      )}
      <div className="space-y-1">
        <Label>{t('station.labels')}</Label>
        <div className="grid grid-cols-4 gap-2">
          {device.labels.map((lab, i) => (
            <Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
              onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
          ))}
        </div>
      </div>
      <div className="flex items-center gap-2">
        {testMsg && (
          <span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
            <span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
            {testMsg.text}
          </span>
        )}
        <div className="ml-auto flex gap-2">
          <Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
            {testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
            {t('station.test')}
          </Button>
          <Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
          <Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
        </div>
      </div>
    </div>
  );
}
