import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar } from 'lucide-react';
import {
  GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
  GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
  StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking,
} from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Button } from '@/components/ui/button';
import { gridToLatLon, splitAtAntimeridian } from '@/lib/maidenhead';
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
import { loadMapView, saveMapView } from '@/lib/mapView';
import { loadMapBase, saveMapBase, MAP_BASE_SAT } from '@/lib/mapBase';
import { writeUiPref } from '@/lib/uiPref';
import { SkyPlot, type SkyPoint } from '@/components/SkyPlot';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';

// Satellites — where the birds are, and what to do with the radio.
//
// The tab answers the three questions a pass poses, and answers them where they
// are asked: how long have I got (the countdown), where is it (the map), what
// do I tune (the readout). Everything else — which satellites to follow, where
// the elements come from, the rotator — is maintenance and lives in Settings.
// During a pass there is no time to configure anything.

type Bird = {
  name: string; norad: number; geostationary: boolean; favorite: boolean;
  has_elements: boolean; element_name: string; epoch_age_h: number;
  transponders?: {
    label: string; mode: string; down_lo: number; down_hi: number;
    up_lo: number; up_hi: number; inverting: boolean; ctcss: number; linear: boolean;
  }[];
};
type Position = {
  name: string; lat: number; lon: number; alt_km: number; footprint_km: number;
  az: number; el: number; range_km: number; range_rate: number;
};
type Pass = {
  name: string; aos: string; los: string; aos_az: number; los_az: number;
  max_el: number; max_el_az: number; max_el_at: string; duration_s: number;
};
type PassInfo = {
  name: string; has_pass: boolean; in_pass: boolean;
  aos: string; los: string; aos_az: number; los_az: number;
  max_el: number; max_el_az: number; max_el_at: string; duration_s: number;
};
type Tuning = {
  name: string; transponder: string; mode: string;
  nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
  ctcss: number; inverting: boolean;
  az: number; el: number; range_km: number; range_rate: number; visible: boolean;
  lat: number; lon: number; alt_km: number; footprint_km: number;
};
type Track = {
  on: boolean; name: string; transponder: string; mode: string;
  nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
  az: number; el: number; visible: boolean;
  radio: string; // "sat" | "downlink-only" | ""
  error: string;
  rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean;
};

const MAP_VIEW_SAT = 'opslog.satMapView';

// The readout column. Wide enough by default to hold a frequency to the hertz
// without wrapping, and adjustable because how much map an operator wants
// against how much detail is theirs to decide — a station watching a footprint
// cross an ocean wants the map, one working a pass wants the numbers.
const SIDE_W_KEY = 'opslog.satSideWidth';
const SIDE_SHOWN_KEY = 'opslog.satSideShown';
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
const SKY_SHOWN_KEY = 'opslog.satSkyShown';

const fmtHz = (hz: number) => {
  if (!hz) return '—';
  // Six decimals: a linear transponder is tuned to the hundred hertz, and the
  // Doppler correction moves the last three digits every second.
  return (hz / 1e6).toFixed(6).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
};
const fmtDeg = (d: number) => `${d.toFixed(1)}°`;
const fmtKm = (km: number) => `${Math.round(km).toLocaleString()} km`;
const hhmm = (iso: string) => {
  const d = new Date(iso);
  return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 16);
};
const hhmmss = (iso: string) => {
  const d = new Date(iso);
  return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 19);
};
const inMin = (iso: string) => Math.round((Date.parse(iso) - Date.now()) / 60000);

// A countdown an operator can act on. Seconds while they matter, then minutes,
// then hours — nobody needs "1h 04m 37s", and nobody wants "0m" for the last
// fifty seconds before a satellite rises.
function fmtCountdown(ms: number): string {
  const s = Math.max(0, Math.round(ms / 1000));
  if (s < 60) return `${s}s`;
  if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
  const h = Math.floor(s / 3600);
  return `${h}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
}

// The eight points of the compass, for an azimuth an operator reads rather than
// computes. "rises at 213°" is a number; "rises SW" is a direction to look in.
const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const compass = (deg: number) => COMPASS[Math.round(((deg % 360) + 360) % 360 / 45) % 8];

// How good a pass is, as a colour. A bird 70° overhead and one scraping 12°
// along the horizon are not the same evening, and the table should say so
// without the operator reading every number.
function elClass(el: number): string {
  if (el >= 50) return 'text-success';
  if (el >= 25) return 'text-foreground';
  if (el >= 15) return 'text-caution';
  return 'text-muted-foreground';
}

// The mode a satellite is worked in, as a dot: FM and SSB call for a completely
// different set-up, and which of the two the next pass is decides whether the
// operator reaches for a handheld or the whole station.
const MODE_COLOUR: Record<string, string> = {
  FM: 'var(--info)',
  SSB: 'var(--success)',
  CW: 'var(--caution)',
  DATA: 'var(--warning)',
};

function ModeDot({ mode }: { mode: string }) {
  const colour = MODE_COLOUR[mode];
  if (!colour) return null;
  return (
    <span
      title={mode}
      className="ml-1.5 inline-block size-1.5 rounded-full align-middle"
      style={{ backgroundColor: colour }}
    />
  );
}

export function SatellitePanel({ myGrid }: { myGrid: string }) {
  const { t } = useI18n();
  const [birds, setBirds] = useState<Bird[]>([]);
  const [sel, setSel] = useState<string>(() => localStorage.getItem('opslog.satSelected') || '');
  const [tpIdx, setTpIdx] = useState(0);
  const [positions, setPositions] = useState<Position[]>([]);
  const [passes, setPasses] = useState<Pass[]>([]);
  const [tuning, setTuning] = useState<Tuning | null>(null);
  const [pass, setPass] = useState<PassInfo | null>(null);
  const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
  const [tracking, setTracking] = useState<Track | null>(null);
  const [sky, setSky] = useState<SkyPoint[]>([]);
  const [skyShown, setSkyShown] = useState(() => localStorage.getItem(SKY_SHOWN_KEY) !== '0');
  const [err, setErr] = useState('');
  // A clock of its own, so every countdown on the panel ticks from one instant
  // and none of them needs a round trip to Go to lose a second.
  const [now, setNow] = useState(() => Date.now());
  useEffect(() => {
    const id = window.setInterval(() => setNow(Date.now()), 1000);
    return () => window.clearInterval(id);
  }, []);

  // What the operator follows. Chosen in Settings; following nothing means
  // every satellite we can both find and tune, which is what somebody who has
  // not chosen yet should see.
  const shown = useMemo(() => {
    const favs = birds.filter((b) => b.favorite);
    if (favs.length > 0) return favs;
    return birds.filter((b) => b.has_elements && (b.transponders?.length ?? 0) > 0);
  }, [birds]);
  const bird = useMemo(() => birds.find((b) => b.name === sel) ?? null, [birds, sel]);
  const tp = bird?.transponders?.[tpIdx] ?? null;
  // The mode each satellite is worked in, for the pass table's dot. Its first
  // transponder: on a bird that has two, the first is the one it is known for.
  const modeOf = useMemo(() => {
    const m = new Map(birds.map((b) => [b.name, b.transponders?.[0]?.mode ?? ''] as const));
    return (name: string) => m.get(name) ?? '';
  }, [birds]);

  // ── Data ─────────────────────────────────────────────────────────────────

  const loadBirds = useCallback(async () => {
    try {
      const list: Bird[] = (await GetSatelliteBirds()) as any;
      setBirds(list ?? []);
    } catch (e: any) { setErr(String(e?.message ?? e)); }
  }, []);

  const loadTle = useCallback(async () => {
    try { setTle((await GetSatelliteTLEInfo()) as any); } catch { /* shown as unknown */ }
  }, []);

  const loadPasses = useCallback(async () => {
    try {
      setPasses(((await GetSatellitePasses([], 0)) as any) ?? []);
      setErr('');
    } catch (e: any) { setErr(String(e?.message ?? e)); }
  }, []);

  useEffect(() => { loadBirds(); loadTle(); loadPasses(); }, [loadBirds, loadTle, loadPasses]);
  useEffect(() => EventsOn('sat:tle', () => { loadTle(); loadBirds(); loadPasses(); }), [loadTle, loadBirds, loadPasses]);
  useEffect(() => { if (sel) localStorage.setItem('opslog.satSelected', sel); }, [sel]);
  useEffect(() => { setTpIdx(0); }, [sel]);

  // Keep the selection inside what is followed: an operator who narrows the list
  // in Settings must not be left looking at a satellite that is no longer there.
  useEffect(() => {
    if (shown.length === 0) return;
    if (!sel || !shown.some((b) => b.name === sel)) setSel(shown[0].name);
  }, [shown, sel]);

  // The map's satellites, every five seconds: a low orbit moves about a third of
  // a degree of longitude in that time, which is a pixel or two at this zoom.
  useEffect(() => {
    let live = true;
    const tick = async () => {
      try {
        const p: Position[] = ((await GetSatellitePositions([])) as any) ?? [];
        if (live) setPositions(p);
      } catch { /* a missing locator is already reported by the passes call */ }
    };
    tick();
    const id = window.setInterval(tick, 5000);
    return () => { live = false; window.clearInterval(id); };
  }, []);

  // The readout, every second: this is the number an operator types into a
  // radio, and a Doppler correction on 70 cm moves by a few tens of hertz a
  // second at the middle of a pass.
  useEffect(() => {
    if (!sel) { setTuning(null); return; }
    let live = true;
    const tick = async () => {
      try {
        const tn: Tuning = (await GetSatelliteTuning(sel, tpIdx, 0)) as any;
        if (live) setTuning(tn);
      } catch { if (live) setTuning(null); }
    };
    tick();
    const id = window.setInterval(tick, 1000);
    return () => { live = false; window.clearInterval(id); };
  }, [sel, tpIdx]);

  // The pass, every twenty seconds. Predicting one steps the orbit across hours;
  // the countdown itself is two timestamps and a clock, which the browser runs.
  useEffect(() => {
    if (!sel) { setPass(null); return; }
    let live = true;
    const tick = async () => {
      try {
        const p: PassInfo = (await GetSatelliteNextPass(sel)) as any;
        if (live) setPass(p);
      } catch { if (live) setPass(null); }
    };
    tick();
    const id = window.setInterval(tick, 20_000);
    return () => { live = false; window.clearInterval(id); };
  }, [sel]);

  // The pass drawn across the sky. Once a minute is plenty: the SHAPE of a
  // pass does not change while it happens — only the marker on it moves, and
  // that comes from the tuning poll a second at a time.
  useEffect(() => {
    if (!sel || !skyShown) { setSky([]); return; }
    let live = true;
    const load = async () => {
      try {
        const pts: SkyPoint[] = ((await GetSatelliteSkyTrack(sel, 120)) as any) ?? [];
        if (live) setSky(pts);
      } catch { if (live) setSky([]); }
    };
    load();
    const id = window.setInterval(load, 60_000);
    return () => { live = false; window.clearInterval(id); };
  }, [sel, skyShown]);
  useEffect(() => { writeUiPref(SKY_SHOWN_KEY, skyShown ? '1' : '0'); }, [skyShown]);

  // Passes are cheap but not free, and they change slowly.
  useEffect(() => {
    const id = window.setInterval(loadPasses, 5 * 60_000);
    return () => window.clearInterval(id);
  }, [loadPasses]);

  // The tracker's own state, pushed as it moves. Polled as well, at a lazy
  // rate, so a panel opened while tracking is already running is not blank
  // until the next tick.
  useEffect(() => {
    const read = async () => {
      try { setTracking((await GetSatelliteTracking()) as any); } catch { /* not tracking */ }
    };
    read();
    const off = EventsOn('sat:track', (s: any) => setTracking(s ?? null));
    const id = window.setInterval(read, 10_000);
    return () => { off(); window.clearInterval(id); };
  }, []);

  const toggleTracking = async () => {
    setErr('');
    try {
      if (tracking?.on) {
        await StopSatelliteTracking();
        setTracking(null);
      } else {
        await StartSatelliteTracking(sel, tpIdx);
        setTracking((await GetSatelliteTracking()) as any);
      }
    } catch (e: any) { setErr(String(e?.message ?? e)); }
  };

  // ── Map ──────────────────────────────────────────────────────────────────

  const divRef = useRef<HTMLDivElement>(null);
  const mapRef = useRef<L.Map | null>(null);
  const layerRef = useRef<L.LayerGroup | null>(null);
  const baseRef = useRef<L.TileLayer | null>(null);
  const labelsRef = useRef<L.TileLayer | null>(null);
  const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_SAT, 'light'));
  const saved = useRef(loadMapView(MAP_VIEW_SAT));
  const [track, setTrack] = useState<Position[]>([]);

  const home = useMemo(() => gridToLatLon(myGrid), [myGrid]);

  // ── The readout column ───────────────────────────────────────────────────

  const [sideW, setSideW] = useState<number>(() => {
    const n = parseFloat(localStorage.getItem(SIDE_W_KEY) || '');
    return Number.isFinite(n) && n >= SIDE_W_MIN && n <= SIDE_W_MAX ? n : SIDE_W_DEFAULT;
  });
  const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0');
  useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]);
  useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]);

  // Dragging the grip. Measured from where the pointer STARTED rather than from
  // the container, and with the pointer captured — without the capture the map
  // underneath swallows the moves the instant the cursor crosses it.
  const startSideDrag = (e: React.PointerEvent) => {
    e.preventDefault();
    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
    const x0 = e.clientX;
    const w0 = sideW;
    const onMove = (ev: PointerEvent) => {
      setSideW(Math.min(SIDE_W_MAX, Math.max(SIDE_W_MIN, Math.round(w0 + (x0 - ev.clientX)))));
    };
    const onUp = () => {
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
    };
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
  };

  useEffect(() => {
    if (!divRef.current || mapRef.current) return;
    const m = L.map(divRef.current, {
      zoomControl: true, attributionControl: true,
      worldCopyJump: false, preferCanvas: true,
      // Opened on the station, not on the Atlantic: the passes that matter are
      // the ones over the operator's own head.
      center: saved.current ? [saved.current.lat, saved.current.lon] : [home?.lat ?? 25, home?.lon ?? 0],
      zoom: saved.current ? saved.current.zoom : 3,
      minZoom: 2,
    });
    m.on('moveend', () => {
      const c = m.getCenter();
      saveMapView(MAP_VIEW_SAT, c.lat, c.lng, m.getZoom());
    });
    mapRef.current = m;
    layerRef.current = L.layerGroup().addTo(m);
    const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
    ro.observe(divRef.current);
    const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
    return () => {
      window.clearTimeout(settle);
      ro.disconnect();
      m.remove();
      mapRef.current = null;
      layerRef.current = null;
    };
  }, [home?.lat, home?.lon]);

  useEffect(() => {
    const m = mapRef.current;
    if (!m) return;
    baseRef.current?.remove(); labelsRef.current?.remove();
    const bm = BASEMAPS[basemap];
    const opts: L.TileLayerOptions = {
      maxNativeZoom: bm.maxNativeZoom, noWrap: true,
      bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
    };
    baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
    if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
    saveMapBase(MAP_BASE_SAT, basemap);
  }, [basemap]);

  // The selected bird's path over the ground, redrawn when the selection
  // changes and every couple of minutes as it walks off the front of it.
  useEffect(() => {
    if (!sel) { setTrack([]); return; }
    let live = true;
    const load = async () => {
      try {
        const pts: Position[] = ((await GetSatelliteGroundTrack(sel, 100)) as any) ?? [];
        if (live) setTrack(pts);
      } catch { if (live) setTrack([]); }
    };
    load();
    const id = window.setInterval(load, 120_000);
    return () => { live = false; window.clearInterval(id); };
  }, [sel]);

  useEffect(() => {
    const layer = layerRef.current;
    if (!layer) return;
    layer.clearLayers();
    if (home) {
      L.circleMarker([home.lat, home.lon], {
        radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
      }).bindTooltip(myGrid, { direction: 'top' }).addTo(layer);
    }
    if (track.length > 1) {
      const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
      L.polyline(pts as L.LatLngExpression[][], {
        color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0,
      }).addTo(layer);
    }
    const wanted = new Set(shown.map((b) => b.name));
    for (const p of positions) {
      if (!wanted.has(p.name) && p.name !== sel) continue;
      const chosen = p.name === sel;
      const up = p.el > 0;
      const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#9ca3af';
      // The footprint is the honest answer to "can I hear it": everything inside
      // the circle has the satellite above its horizon.
      L.circle([p.lat, p.lon], {
        radius: p.footprint_km * 1000,
        color: colour, weight: chosen ? 1.2 : 0.8, opacity: chosen ? 0.7 : 0.35,
        fillColor: colour, fillOpacity: chosen ? 0.1 : 0.05,
      }).addTo(layer);
      L.circleMarker([p.lat, p.lon], {
        radius: chosen ? 6 : 4, color: '#fff', weight: 1,
        fillColor: colour, fillOpacity: 1,
      })
        .bindTooltip(`${p.name} · ${fmtDeg(p.el)} · ${Math.round(p.alt_km)} km`, { direction: 'top' })
        .on('click', () => setSel(p.name))
        .addTo(layer);
    }
  }, [positions, track, home?.lat, home?.lon, myGrid, sel, shown]);

  // ── Render ───────────────────────────────────────────────────────────────

  // The pass, as a countdown and a bar. Both derived here from two timestamps,
  // so they move every second without asking Go anything.
  const aosMs = pass?.has_pass ? Date.parse(pass.aos) : 0;
  const losMs = pass?.has_pass ? Date.parse(pass.los) : 0;
  const inPass = !!pass?.has_pass && now >= aosMs && now < losMs;
  const progress = inPass && losMs > aosMs ? (now - aosMs) / (losMs - aosMs) : 0;

  return (
    <div className="flex flex-col h-full min-h-0 gap-1 p-1">
      {/* Header: what to work, and the one button that touches the radio.
          Everything else about satellites — which ones, where the elements come
          from, the rotator — is in Settings, because none of it is something to
          do while a bird is going over. */}
      <div className="flex items-center gap-2 flex-wrap px-1 shrink-0">
        <SatIcon className="size-4 text-muted-foreground" />
        <select
          className="h-7 rounded-md border border-border bg-background px-2 text-xs min-w-[12rem]"
          value={sel}
          onChange={(e) => setSel(e.target.value)}
        >
          {shown.length === 0 && <option value="">{t('sat.noneFollowed')}</option>}
          {shown.map((b) => (
            <option key={b.name} value={b.name} disabled={!b.has_elements && !b.geostationary}>
              {b.name}{b.has_elements || b.geostationary ? '' : ` — ${t('sat.noElements')}`}
            </option>
          ))}
        </select>
        {(bird?.transponders?.length ?? 0) > 1 && (
          <select
            className="h-7 rounded-md border border-border bg-background px-2 text-xs"
            value={tpIdx}
            onChange={(e) => setTpIdx(Number(e.target.value))}
          >
            {bird!.transponders!.map((x, i) => (
              <option key={i} value={i}>{x.label}</option>
            ))}
          </select>
        )}
        <Button
          size="sm"
          variant={tracking?.on ? 'default' : 'outline'}
          className={cn('h-7 px-2 gap-1.5', tracking?.on && 'bg-success text-background hover:bg-success/90')}
          onClick={toggleTracking}
          disabled={!bird?.transponders?.length}
          title={tracking?.on
            ? (tracking.radio === 'sat' ? t('sat.trackingFull') : t('sat.trackingDown'))
            : t('sat.trackTip')}
        >
          <Radio className="size-3.5" />
          {tracking?.on ? t('sat.tracking') : t('sat.track')}
        </Button>
        {tracking?.on && tracking.radio === 'downlink-only' && (
          <span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
        )}
        <div className="flex-1" />
        {/* Elements are maintenance, so only their AGE is here — and only when
            it has become a reason the panel might be wrong. */}
        {tle?.stale && <span className="text-[11px] text-warning">{t('sat.tleStale')}</span>}
        <select
          className="h-7 rounded-md border border-border bg-background px-2 text-xs"
          value={basemap}
          onChange={(e) => setBasemap(e.target.value as BasemapKey)}
        >
          {Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
        </select>
        <Button
          variant="ghost" size="sm"
          className={cn('h-7 px-1.5', skyShown && 'text-success')}
          onClick={() => setSkyShown((v) => !v)}
          title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
        >
          <Radar className="size-3.5" />
        </Button>
        {/* Put the whole window on the map. On a laptop the readout takes a
            third of the screen, and there are moments — watching a footprint
            cross an ocean — when the map IS the answer. */}
        <Button
          variant="ghost" size="sm" className="h-7 px-1.5"
          onClick={() => setSideShown((v) => !v)}
          title={sideShown ? t('sat.hideSide') : t('sat.showSide')}
        >
          {sideShown ? <PanelRightClose className="size-3.5" /> : <PanelRightOpen className="size-3.5" />}
        </Button>
      </div>

      {err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}

      <div className="flex gap-1 flex-1 min-h-0">
        {/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its
            own panes and controls up to z-index 1000, which without a stacking
            context of their own float over Preferences and every dialog in the
            app — the map ends up on top of the very buttons that would close
            it. */}
        <div className="relative isolate z-0 flex-1 min-w-0 rounded-lg overflow-hidden border border-border">
          <div ref={divRef} className="h-full w-full" />
        </div>

        {sideShown && (
          <div
            role="separator"
            aria-orientation="vertical"
            title={t('sat.sideWidthTip')}
            onPointerDown={startSideDrag}
            onDoubleClick={() => setSideW(SIDE_W_DEFAULT)}
            className="group relative shrink-0 w-2 cursor-col-resize flex items-center justify-center"
          >
            <span className="h-10 w-[3px] rounded-full bg-border group-hover:bg-primary transition-colors" />
          </div>
        )}

        <div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')}
          style={{ width: sideW }}>
          {/* The pass. The first thing an operator looks at and the reason they
              sit down: how long have I got, and how high does it get. */}
          <div className={cn('rounded-lg border bg-card p-2',
            inPass ? 'border-success/60' : 'border-border')}>
            <div className="flex items-baseline justify-between gap-2">
              <span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
              <span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? ''}</span>
            </div>

            {bird?.geostationary ? (
              <div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
            ) : !pass?.has_pass ? (
              <div className="mt-1 text-[11px] text-muted-foreground">{t('sat.noPassSoon')}</div>
            ) : (
              <>
                <div className="mt-1 flex items-baseline gap-2">
                  <span className={cn('text-[10px] uppercase tracking-wide',
                    inPass ? 'text-success' : 'text-muted-foreground')}>
                    {inPass ? t('sat.los') : t('sat.aos')}
                  </span>
                  <span className={cn('text-xl font-semibold tabular-nums leading-none',
                    inPass && 'text-success')}>
                    {fmtCountdown((inPass ? losMs : aosMs) - now)}
                  </span>
                  <span className="text-[11px] text-muted-foreground tabular-nums ml-auto">
                    {hhmmss(inPass ? pass.los : pass.aos)}Z
                  </span>
                </div>

                {/* Where in the pass we are. A bar because the useful question
                    mid-pass is not the clock but "am I past the peak". */}
                <div className="mt-1.5 h-1 rounded-full bg-muted overflow-hidden">
                  <div className="h-full bg-success transition-[width] duration-1000 ease-linear"
                    style={{ width: `${Math.round(progress * 100)}%` }} />
                </div>

                <div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
                  <PassBit label={t('sat.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} />
                  <PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`}
                    strong={pass.max_el >= 30} />
                  <PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
                </div>
              </>
            )}
          </div>

          {/* The sky, seen from underneath it: the centre is straight up, the
              rim is the horizon, north is at the top. One glance says whether
              the pass comes over the roof or along the treeline. */}
          {skyShown && (
            <div className="rounded-lg border border-border bg-card p-2">
              <SkyPlot
                track={sky}
                az={tuning?.az ?? null}
                el={tuning?.el ?? null}
                name={bird?.name}
                visible={!!tuning?.visible}
              />
            </div>
          )}

          {/* Where it is, right now. */}
          <div className="rounded-lg border border-border bg-card p-2">
            <div className="grid grid-cols-2 gap-2">
              <Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
                sub={tuning ? compass(tuning.az) : ''} />
              <Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
                colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
                sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
            </div>

            <div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
              <PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
              <PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
              <PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
            </div>

            {/* Approaching or receding, which is the sign of the whole Doppler
                correction and the one number that explains why the frequencies
                are moving the way they are. */}
            {!!tuning && !bird?.geostationary && (
              <div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
                {tuning.range_rate < 0
                  ? <ArrowUp className="size-3 text-success" />
                  : <ArrowDown className="size-3 text-warning" />}
                <span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
                <span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
              </div>
            )}

            {/* Where the antenna is, beside where the satellite is. The two
                differing is a rotator still slewing; the two differing for a
                long time is a rotator that is stuck, and that is worth being
                able to see without walking outside. */}
            {tracking?.rot_on && (
              <div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
                <span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
                <span className="font-medium">{fmtDeg(tracking.rot_az)} / {fmtDeg(tracking.rot_el)}</span>
                {!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
              </div>
            )}
          </div>

          {/* What to tune. */}
          <div className="rounded-lg border border-border bg-card p-2">
            <div className="space-y-1">
              <FreqRow label={t('sat.down')} hz={tuning?.down_hz ?? 0} nominal={tuning?.nominal_down ?? 0} />
              <FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
            </div>
            <div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
              {!!tp?.mode && <span>{tp.mode}</span>}
              {!!tp?.ctcss && <span>CTCSS {tp.ctcss.toFixed(1)}</span>}
              {tp?.inverting && <span>{t('sat.inverting')}</span>}
              {tp?.linear && <span>{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</span>}
              {bird?.geostationary && <span>{t('sat.geo')}</span>}
            </div>
          </div>

          {/* What is coming. */}
          <div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col">
            <div className="px-2 py-1 text-[11px] font-medium text-muted-foreground border-b border-border shrink-0">
              {t('sat.nextPasses')}
            </div>
            <div className="flex-1 min-h-0 overflow-auto">
              {passes.length === 0 && (
                <div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
              )}
              {passes.length > 0 && (
                // A real table, so the name column takes the width the longest
                // name needs — "ZHUHAI-1 OVS-1A" was cut to eight characters in
                // a fixed one — and the rest keeps its columns lined up under
                // headings that say what the numbers are.
                <table className="w-full text-[11px] tabular-nums">
                  <thead className="sticky top-0 z-10 bg-card">
                    <tr className="text-[10px] uppercase tracking-wide text-muted-foreground">
                      <th className="text-left font-medium px-2 py-1">{t('sat.thSat')}</th>
                      <th className="text-left font-medium py-1">{t('sat.thAos')}</th>
                      <th className="text-left font-medium py-1">{t('sat.thLos')}</th>
                      <th className="text-right font-medium py-1">{t('sat.thMaxEl')}</th>
                      <th className="text-right font-medium px-2 py-1">{t('sat.thIn')}</th>
                    </tr>
                  </thead>
                  <tbody>
                    {passes.map((p, i) => {
                      const aos = Date.parse(p.aos);
                      const running = aos <= now && Date.parse(p.los) > now;
                      const soon = !running && aos - now < 5 * 60_000;
                      return (
                        <tr
                          key={`${p.name}-${p.aos}-${i}`}
                          onClick={() => setSel(p.name)}
                          className={cn(
                            'cursor-pointer hover:bg-accent/50 border-t border-border/40',
                            p.name === sel && 'bg-accent/40',
                            running && 'bg-success/10',
                          )}
                        >
                          <td className="px-2 py-1 whitespace-nowrap">
                            <span className={cn('font-medium', running && 'text-success')}>{p.name}</span>
                            <ModeDot mode={modeOf(p.name)} />
                          </td>
                          <td className="py-1 whitespace-nowrap">{hhmm(p.aos)}</td>
                          <td className="py-1 whitespace-nowrap text-muted-foreground">{hhmm(p.los)}</td>
                          {/* The elevation is the quality of the pass, so it is
                              coloured like one: a 70° pass overhead and a 12°
                              scrape along the horizon are not the same evening. */}
                          <td className={cn('py-1 text-right font-medium', elClass(p.max_el))}>
                            {Math.round(p.max_el)}°
                          </td>
                          <td className={cn('px-2 py-1 text-right whitespace-nowrap',
                            running ? 'text-success font-medium' : soon ? 'text-warning' : 'text-muted-foreground')}>
                            {running ? t('sat.now') : fmtCountdown(aos - now)}
                          </td>
                        </tr>
                      );
                    })}
                  </tbody>
                </table>
              )}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

function Readout({ label, value, colour, sub }: { label: string; value: string; colour?: string; sub?: string }) {
  return (
    <div className="rounded-md bg-muted/40 px-2 py-1">
      <div className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</div>
      <div className="flex items-baseline gap-1.5">
        <span className="text-lg font-semibold tabular-nums leading-tight" style={colour ? { color: colour } : undefined}>
          {value}
        </span>
        {!!sub && <span className="text-[10px] text-muted-foreground">{sub}</span>}
      </div>
    </div>
  );
}

function PassBit({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
  return (
    <div className="min-w-0">
      <div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div>
      <div className={cn('truncate', strong && 'font-semibold text-foreground')}>{value}</div>
    </div>
  );
}

// The corrected frequency large, the nominal one small beside it. Showing only
// one of them leaves an operator unable to tell a Doppler correction from a
// mistuned transponder.
function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) {
  const shift = hz && nominal ? hz - nominal : 0;
  return (
    <div className="flex items-baseline gap-2">
      <span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
      <span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
      {!!shift && (
        <span className="text-[10px] tabular-nums text-muted-foreground">
          {shift > 0 ? '+' : '−'}{Math.abs(Math.round(shift))} Hz
        </span>
      )}
    </div>
  );
}
