import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { MotorAntennaWidget, type AntStatus } from '@/components/MotorAntennaWidget';
import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
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,
  RotatorGoTo, RotatorStop, SetActiveRotor,
  GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
  MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
  ListDenkoviDevices, ListSerialPorts, TestStationDevice,
  GetAmpStatuses, GetFlexState,
  GetTunerGeniusStatus, GetTunerGeniusSettings,
  GetPSUStatus, GetPSUSettings, SetPSUOutput,
} from '../../wailsjs/go/main/App';

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

type PSUState = { connected: boolean; on: boolean; volts: number; amps: number; watts: number; set_volts: number; set_amps: number; protected: number; error?: string };

// The bench supply. One button — the output — and the three numbers that say
// what it is actually delivering.
//
// The SET points are shown beside them, small, because "13.8 V set" next to
// "0.02 A out" is how an operator sees at a glance that the supply is on but
// the radio is not drawing. They are not editable: OpsLog reads them and never
// writes them, which is the whole safety story of this device.
function PSUCard({ st, busy, onToggle, t }: {
  st: PSUState; busy: boolean; onToggle: (on: boolean) => void; t: (k: string, v?: any) => string;
}) {
  const tripped = (st.protected ?? 0) !== 0;
  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">
        <Power className="size-4 text-primary" />
        <div className="text-sm font-semibold truncate">{t('psu.title')}</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('psu.offline'))} />
      </div>
      <div className="p-3 space-y-2">
        <div className="flex items-center gap-3">
          <button type="button" disabled={!st.connected || busy}
            onClick={() => onToggle(!st.on)}
            className={cn('flex items-center gap-2 rounded-md border px-3 py-1.5 transition-colors disabled:opacity-40',
              st.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
            <span className={cn('flex items-center justify-center size-6 rounded shrink-0',
              st.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
              {busy ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
            </span>
            <span className="text-xs font-semibold">{t('psu.output')}</span>
            <span className={cn('text-[10px] font-bold', st.on ? 'text-success' : 'text-muted-foreground/60')}>
              {st.on ? t('station.on') : t('station.off')}
            </span>
          </button>
          <div className="flex-1 min-w-0 font-mono tabular-nums text-right">
            <span className="text-lg font-bold">{(st.volts ?? 0).toFixed(2)}</span><span className="text-xs text-muted-foreground"> V</span>
            <span className="text-lg font-bold ml-3">{(st.amps ?? 0).toFixed(3)}</span><span className="text-xs text-muted-foreground"> A</span>
          </div>
        </div>
        <div className="flex items-center justify-between text-[11px] text-muted-foreground font-mono">
          <span>{(st.watts ?? 0).toFixed(1)} W</span>
          <span>{t('psu.setTo')} {(st.set_volts ?? 0).toFixed(2)} V / {(st.set_amps ?? 0).toFixed(3)} A</span>
        </div>
        {tripped && (
          <div className="text-[11px] font-bold text-danger">{t('psu.tripped')} (0x{(st.protected ?? 0).toString(16).toUpperCase()})</div>
        )}
      </div>
    </div>
  );
}

type Device = {
  id: string; type: string; name: string; host: string;
  user?: string; pass?: string; channels?: number; labels: string[];
  // Generic HTTP board only. The per-relay URLs win over the patterns.
  on_urls?: string[]; off_urls?: string[]; on_pattern?: string; off_pattern?: string; insecure_tls?: boolean;
};
type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };

// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
// packed tighter but left the row edges ragged, which read as untidy on a wide
// screen; aligned rows are what an operator expects from a dashboard.
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
const GRID_GAP = 16; // px between cards, both axes

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

// 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.
// chanCount is the relay count for a device. It MUST agree with
// deviceRelayCount in app.go: the two decide how many labels and URL rows the
// editor shows and how many relays the driver is built for, and a type known to
// one and not the other silently pins the count to its fixed default — which is
// exactly what happened when the HTTP board was added here and not below.
const chanCount = (d: Device): number =>
  (d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8)
    : d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
      : d.type === 'httpgen' ? (d.channels && d.channels >= 1 ? d.channels : 4)
        : (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; rotors?: string[]; active?: 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">
        {/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
            app carries it rather than each caller drawing its own. */}
        <RotorCompass
          bearing={bearing ?? null}
          headings={hd.ok ? [hd.azimuth] : []}
          centerLat={centerLat ?? null}
          centerLon={centerLon ?? null}
          rotorEnabled={hd.ok}
          rotors={hd.rotors}
          activeRotor={hd.active}
          onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
          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>
  );
}


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

  // How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
  // cards as the window allows; an explicit count is honoured as asked, even
  // past that width — the operator can see his own screen.
  const gridRef = useRef<HTMLDivElement | null>(null);
  const [colCount, setColCount] = useState(1);

  useLayoutEffect(() => {
    if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
    const el = gridRef.current;
    if (!el) return;
    const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [cols]);
  // 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); };
  }, []);

  // Bench power supply. Polled slower than the tuner: it has no meters that
  // track a transmission, and every poll is three Modbus exchanges on a 9600
  // baud line the operator may also be using to switch the output.
  const [psu, setPsu] = useState<PSUState>({ connected: false, on: false, volts: 0, amps: 0, watts: 0, set_volts: 0, set_amps: 0, protected: 0 });
  const [psuEnabled, setPsuEnabled] = useState(false);
  const [psuBusy, setPsuBusy] = useState(false);
  useEffect(() => {
    let alive = true;
    const load = async () => {
      try { const en: any = await GetPSUSettings(); if (alive) setPsuEnabled(!!en?.enabled); } catch {}
      try { const st: any = await GetPSUStatus(); if (alive && st) setPsu(st as PSUState); } catch {}
    };
    load();
    const id = window.setInterval(load, 1500);
    return () => { alive = false; window.clearInterval(id); };
  }, []);
  const togglePSU = useCallback(async (on: boolean) => {
    setPsuBusy(true);
    // No optimistic flip here, unlike the relays: the supply echoes the value it
    // actually set, so showing ON before it confirms would be showing something
    // OpsLog does not know. A power switch is the wrong place to guess.
    try { await SetPSUOutput(on); const st: any = await GetPSUStatus(); if (st) setPsu(st as PSUState); }
    catch { /* the poll will tell the truth */ }
    finally { setPsuBusy(false); }
  }, []);

  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 pollAnt = useCallback(async () => {
    try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
  }, []);

  useEffect(() => { loadDevices(); }, [loadDevices]);
  // The heading comes from the shared poller: one loop for the whole app, fast
  // while the antenna turns and slow while it is parked. Every poll opens and
  // closes the controller's port, and this panel used to run its own alongside
  // the status bar's — the same controller asked twice over.
  useEffect(() => subscribeRotorHeading((h) => setRot(h as any)), []);
  useEffect(() => {
    poll(); pollAnt();
    const id = window.setInterval(() => { poll(); pollAnt(); }, 3000);
    return () => window.clearInterval(id);
  }, [poll, 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 }));
    // The generic HTTP board has no address of its own and nothing to poll: its
    // relays can each live on a different box, and no status endpoint is read
    // back. So no host under the name, no online dot, and the buttons are never
    // greyed out waiting for a connection that is never made.
    const fireAndForget = dev.type === 'httpgen';
    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]}{fireAndForget || !dev.host ? '' : ` · ${dev.host}`}
            </div>
          </div>
          {fireAndForget ? <span className="ml-auto" /> : (
            <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={!fireAndForget && !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 take two grid columns instead of one. The amplifier and tuner
  // carry meter rows and a channel selector that are unreadable squeezed into a
  // single ~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={pokeRotorHeading} 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 });
  if (psuEnabled) widgets.push({ id: 'psu', node: <PSUCard st={psu} busy={psuBusy} onToggle={togglePSU} t={t} /> });
  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', '5', '6'] 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>
      )}

      {/* Dashboard grid: equal columns, and every card stretches to the height of
          the tallest one on its row, so the rows line up instead of ending
          ragged. One column each, two for the amplifier/tuner whose meter rows
          need the room. Each card has a grip handle (left rail) as the drag
          initiator, since the body is full of buttons. */}
      <div ref={gridRef} style={{
        display: 'grid',
        gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
        gap: `${GRID_GAP}px`,
      }}>
        {ordered.map((w) => (
          <div key={w.id}
            // A wide card takes two columns — but never more than there are, or
            // the grid would grow a phantom column on a narrow window.
            style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
            onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
            onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
            {/* h-full down the chain is what makes the card fill the row height
                rather than sit at its natural size in a taller cell. */}
            <div className="flex items-stretch h-full">
              <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 [&>*]:h-full">{w.node}</div>
            </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 isDingtian = device.type === 'dingtian';
  const isDenkovi = device.type === 'denkovi';
  const isUsbRelay = device.type === 'usbrelay';
  const isHTTPGen = device.type === 'httpgen';
  // {value} sends a relay's label, so a URL using it on an unnamed relay would
  // go out with an empty parameter. Warn while it is being typed rather than at
  // the moment an antenna fails to switch.
  const valueNeedsLabels = isHTTPGen
    && [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
      .some((s) => (s ?? '').includes('{value}'))
    && device.labels.some((l) => !l.trim());
  // Any https:// among this board's URLs. A relay box on the LAN signs its own
  // certificate, so HTTPS to one cannot be verified — the operator has to say
  // whether to accept that, and the question only arises once they type https.
  const usesHTTPS = isHTTPGen
    && [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
      .some((u) => (u ?? '').trim().toLowerCase().startsWith('https://'));
  // 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="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
              <SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
              <SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
              <SelectItem value="httpgen">{t('station.typeHttpGen')}</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 || isDingtian || isHTTPGen) && (
        <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] : isDingtian ? [2, 4, 8, 16, 24, 32] : [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>
      ) : isHTTPGen ? null : (
      /* No Host for the generic board: its driver never reads one. Each URL
         below carries its own address, and they need not even share it — one
         relay can sit on a different box from the next. A field that changes
         nothing is worse than no field: it reads as the thing to fill in first,
         and then the URLs look like they should be relative to it. */
      <div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
        <div className={cn('space-y-1', (isKM || isDingtian) ? '' : '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 })} />
          <span className="text-[10px] text-muted-foreground">{t('station.hostHint')}</span>
        </div>
        {/* Dingtian: both are OFF on a factory board — the session ID only when
            "HTTP Session" is enabled in its web page, the password only when a
            relay password is set. */}
        {isDingtian && (
          <>
            <div className="space-y-1">
              <Label>{t('station.dtSession')}</Label>
              <Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
                onChange={(e) => onChange({ ...device, user: e.target.value })} />
            </div>
            <div className="space-y-1">
              <Label>{t('station.dtPwd')}</Label>
              <Input className="font-mono" value={device.pass ?? ''} placeholder="0"
                onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
            </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>
      )}
      {/* Generic HTTP board: a URL to switch each relay on and one to switch it
          off. Two ways to fill it, and the per-relay one is why this type
          exists — a hand-made switch often has URLs with nothing in common
          between channels, which no pattern can express. */}
      {isHTTPGen && (
        <div className="space-y-3 rounded-md border border-border/60 p-3">
          <div className="grid grid-cols-2 gap-3">
            <div className="space-y-1">
              <Label>{t('station.onPattern')}</Label>
              <Input className="font-mono text-xs" placeholder="http://192.168.1.9/relay?n={relay}&state=on"
                value={device.on_pattern ?? ''}
                onChange={(e) => onChange({ ...device, on_pattern: e.target.value })} />
            </div>
            <div className="space-y-1">
              <Label>{t('station.offPattern')}</Label>
              <Input className="font-mono text-xs" placeholder="http://192.168.1.9/relay?n={relay}&state=off"
                value={device.off_pattern ?? ''}
                onChange={(e) => onChange({ ...device, off_pattern: e.target.value })} />
            </div>
          </div>
          <div className="text-[10px] text-muted-foreground">{t('station.patternHint')}</div>
          {/* Shown only once an https:// URL is actually in use. A board on
              plain HTTP has no certificate to argue about, and an option that
              cannot matter yet is one more thing to wonder about. */}
          {usesHTTPS && (
            <label className="flex items-start gap-2 text-xs cursor-pointer">
              <Checkbox className="mt-0.5" checked={!!device.insecure_tls}
                onCheckedChange={(c) => onChange({ ...device, insecure_tls: !!c })} />
              <span>
                {t('station.insecureTls')}
                <span className="block text-[10px] text-muted-foreground">{t('station.insecureTlsHint')}</span>
              </span>
            </label>
          )}
          <div className="space-y-1">
            <Label>{t('station.perRelayUrls')}</Label>
            <div className="space-y-1">
              {device.labels.map((_, i) => (
                <div key={i} className="grid grid-cols-[2.5rem_1fr_1fr] items-center gap-2">
                  <span className="text-[11px] text-muted-foreground">{i + 1}</span>
                  <Input className="h-8 font-mono text-[11px]" placeholder={t('station.onUrlPh')}
                    value={device.on_urls?.[i] ?? ''}
                    onChange={(e) => {
                      const on_urls = [...(device.on_urls ?? [])];
                      while (on_urls.length < device.labels.length) on_urls.push('');
                      on_urls[i] = e.target.value;
                      onChange({ ...device, on_urls });
                    }} />
                  <Input className="h-8 font-mono text-[11px]" placeholder={t('station.offUrlPh')}
                    value={device.off_urls?.[i] ?? ''}
                    onChange={(e) => {
                      const off_urls = [...(device.off_urls ?? [])];
                      while (off_urls.length < device.labels.length) off_urls.push('');
                      off_urls[i] = e.target.value;
                      onChange({ ...device, off_urls });
                    }} />
                </div>
              ))}
            </div>
            <div className="text-[10px] text-muted-foreground">{t('station.perRelayHint')}</div>
          </div>
        </div>
      )}

      <div className="space-y-1">
        <Label>{t('station.labels')}</Label>
        {/* {value} sends the label, so an unnamed relay would go out as "?on=".
            Said here, beside the empty box, rather than when the antenna fails
            to switch and the log is the only place that explains why. */}
        {valueNeedsLabels && <p className="text-[10px] text-warning">{t('station.valueNeedsLabels')}</p>}
        <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">
          {/* No connection test for the generic board, and no host required to
              save it. There is nothing to test: it has no address of its own and
              no status to read — its URLs are fired and forgotten. A button that
              can only ever say "OK, 4 relays" tests nothing, and a Save greyed
              out for a missing host made a perfectly complete configuration —
              four full URLs — impossible to store. */}
          {!isHTTPGen && (
            <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={!isHTTPGen && !device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
        </div>
      </div>
    </div>
  );
}
