// RotorCompass — an azimuthal-equidistant rotor display (à la 4O3A RotorGenius).
//
// A world map centred on the operator's QTH (north up) fills the dial, ringed by
// a square scale. A green pointer shows the antenna heading, an orange one
// follows the mouse so a click lands where it is aimed, and a yellow dot marks
// the azimuth that was ordered until the rotor gets there. Green and red dots on
// the inner ring are the short and long path to the DX.
//
// Two shapes, chosen by the caller: with presets or a Stop handler it draws the
// full widget (dial + readout + azimuth box + quick turns), without them the
// dial alone. Station Control draws its own controls around the compass, and two
// sets of the same buttons side by side is nothing but confusing.
import { useEffect, useId, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react';
import { geoAzimuthalEquidistant, geoPath } from 'd3-geo';
import { feature } from 'topojson-client';
import landTopo from 'world-atlas/land-110m.json';
import { Compass, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';

// Decode the coastline outline once (≈110 m simplified land polygons).
const LAND = feature(landTopo as any, (landTopo as any).objects.land);

interface Props {
  bearing?: number | null;       // short-path azimuth to DX (deg)
  headings: number[];            // radiating heading(s) — rotor + Ultrabeam pattern
  boomHeading?: number | null;   // mechanical boom (rotor) azimuth, shown grey when it differs
  pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
  centerLat?: number | null;     // operator latitude (projection centre)
  centerLon?: number | null;     // operator longitude
  rotorEnabled?: boolean;
  rotors?: string[];             // logical rotor names; >1 → show a selector
  activeRotor?: number;          // index of the selected rotor (0-based)
  onSelectRotor?: (i: number) => void; // switch the active rotor
  onGoto?: (az: number) => void; // click-to-turn
  onClose?: () => void;
  presets?: { label: string; azimuth: number }[];
  onStop?: () => void;
}

type RotorPreset = { label: string; azimuth: number };

// ── Timings ────────────────────────────────────────────────────────────────
// A rotor is slow and its readout is coarse, so "is it moving" is inferred
// rather than reported: a heading that changes by more than a degree means it
// is, and it is considered stopped once the readout has been still for a while.
const MOVEMENT_SETTLE_MS = 1600;
// Four degrees, not one.
//
// A rotor at rest does not report a constant heading: the potentiometer and the
// controller's rounding walk the reading a degree or two either side, and at one
// degree that jitter WAS movement — Stop lit for a second and a half, went out,
// and lit again, for an antenna that had not turned all evening. The reference
// is only moved when the threshold is crossed, so a rotor genuinely turning
// accumulates towards it however slowly it goes; noise around a value never
// gets there.
const MOVEMENT_TRIGGER_DEG = 4;
// How long an order is given to produce movement before the widget stops
// claiming the antenna is turning — the rotor may already have been there.
const COMMAND_START_TIMEOUT_MS = 2500;
// Arrival: within a degree of the target, held for long enough not to be one
// noisy reading, then the marker fades rather than vanishing (a marker that
// disappears between two frames reads as a fault).
const TARGET_REACHED_DEG = 1;
const TARGET_CONFIRM_MS = 700;
const TARGET_FADE_MS = 500;

// ── Palette ────────────────────────────────────────────────────────────────
// Deliberately fixed, not themed: this is a map, and a map that repaints itself
// in four colour schemes stops being readable. The chrome around it — card,
// borders, buttons — follows the theme as everything else does.
const COMPASS_ORANGE = '#F97316';
// The target is yellow in BOTH places it appears: the figure under the current
// azimuth and the dot on the dial. One idea, one colour.
const TARGET_YELLOW = '#FBBF24';
const MAP_BG_TOP = '#0B1015';
const MAP_BG_BOTTOM = '#080C11';
const MAP_LAND = '#202832';
const MAP_LAND_SECONDARY = '#25303A';

// What each rotor was last seen at, and what it was last told to do, kept
// OUTSIDE the component and keyed by rotor index.
//
// The widget is unmounted whenever its panel is hidden or the layout reflows,
// and telemetry only arrives every second or so: without this, coming back
// showed a blank dial and forgot the azimuth that had been ordered thirty
// seconds earlier, which is exactly when an operator looks.
const rememberedAzimuths = new Map<number, number>();
const rememberedTargets = new Map<number, number>();

function normalizeAzimuth(value: number): number {
  return ((Math.round(value) % 360) + 360) % 360;
}

function normalizeRotation(value: number): number {
  return ((value % 360) + 360) % 360;
}

function angularDistance(a: number, b: number): number {
  const aa = normalizeAzimuth(a);
  const bb = normalizeAzimuth(b);
  return Math.abs(((aa - bb + 540) % 360) - 180);
}

// unwrapRotation keeps a CSS rotation continuous across north.
//
// 358 → 359 → 0 → 1 is a pointer that spins the long way round the dial on
// every pass; expressed as 358 → 359 → 360 → 361 the transition animates the
// way the antenna actually moves.
function unwrapRotation(nextAngle: number, previousRotation: number | null): number {
  if (previousRotation == null) return nextAngle;
  const previousNormalized = normalizeRotation(previousRotation);
  const delta = ((nextAngle - previousNormalized + 540) % 360) - 180;
  return previousRotation + delta;
}

// ── The dial ───────────────────────────────────────────────────────────────

function RotorCompassDial({
  azimuth, secondary, boom, targetAzimuth, targetFading,
  shortPath, longPath, centerLat, centerLon, onGoto,
}: {
  azimuth: number | null;
  // The second lobe of a bidirectional Ultrabeam, and the mechanical boom when
  // the antenna radiates somewhere other than where it points.
  secondary?: number | null;
  boom?: number | null;
  targetAzimuth: number | null;
  targetFading: boolean;
  shortPath: number | null;
  longPath: number | null;
  centerLat?: number | null;
  centerLon?: number | null;
  onGoto?: (az: number) => void;
}) {
  // Gradient and mask ids must be unique per instance: two compasses on one
  // screen (docked widget + Station Control) would otherwise share the first
  // one's definitions.
  const uid = useId().replace(/:/g, '');
  const bgGradientId = `rotor-bg-${uid}`;
  const mapFadeGradientId = `rotor-map-fade-grad-${uid}`;
  const mapFadeMaskId = `rotor-map-fade-mask-${uid}`;

  const SIZE = 320;
  const CENTER = SIZE / 2;

  // The scale is a SQUARE ring, not a circle: it puts the tick marks at the
  // edge of the panel, which is where the room is.
  const SCALE_HALF = 108;
  const CARDINAL_TICK_OUTER_HALF = SCALE_HALF + 7;
  const MAJOR_TICK_INNER_HALF = SCALE_HALF - 17;
  const POINTER_TIP_RADIUS = 72;
  const CARDINAL_LABEL_INSET = 23;
  const CENTER_DOT_RADIUS = 4.2;
  const MAP_RADIUS = CENTER - 1;
  // Around the centre the cursor's direction is meaningless — a pixel either
  // way is forty degrees — so no heading is derived there.
  const HOVER_DEAD_ZONE = 14;

  const [hoverAzimuth, setHoverAzimuth] = useState<number | null>(null);
  const [hoverRotation, setHoverRotation] = useState<number | null>(null);
  const hoverRotationRef = useRef<number | null>(null);

  const landPath = useMemo(() => {
    if (centerLat == null || centerLon == null) return '';
    const projection = geoAzimuthalEquidistant()
      .rotate([-centerLon, -centerLat])
      .clipAngle(179.9)
      .scale(MAP_RADIUS / Math.PI)
      .translate([CENTER, CENTER]);
    return geoPath(projection as any)(LAND as any) || '';
  }, [centerLat, centerLon]);

  // A point on the square scale at a given azimuth.
  const squarePoint = (angle: number, halfExtent: number) => {
    const radians = (normalizeAzimuth(angle) * Math.PI) / 180;
    const dx = Math.sin(radians);
    const dy = -Math.cos(radians);
    const divisor = Math.max(Math.abs(dx), Math.abs(dy), 0.0001);
    const scale = halfExtent / divisor;
    return { x: CENTER + dx * scale, y: CENTER + dy * scale };
  };

  const radialPoint = (angle: number, radius: number) => {
    const radians = (normalizeAzimuth(angle) * Math.PI) / 180;
    return { x: CENTER + Math.sin(radians) * radius, y: CENTER - Math.cos(radians) * radius };
  };

  const radialDistanceToSquare = (angle: number, halfExtent: number) => {
    const radians = (normalizeAzimuth(angle) * Math.PI) / 180;
    const dx = Math.sin(radians);
    const dy = -Math.cos(radians);
    return halfExtent / Math.max(Math.abs(dx), Math.abs(dy), 0.0001);
  };

  // Markers sit halfway between the pointer's tip and the scale, so they stay
  // clear of both whatever direction they are in — the ring is a square.
  const markerRadius = (angle: number) => {
    const tickInnerRadius = radialDistanceToSquare(angle, MAJOR_TICK_INNER_HALF);
    return POINTER_TIP_RADIUS + (tickInnerRadius - POINTER_TIP_RADIUS) / 2;
  };

  const azimuthFromMouseEvent = (event: ReactMouseEvent<SVGSVGElement>): number | null => {
    const rect = event.currentTarget.getBoundingClientRect();
    const svgX = ((event.clientX - rect.left) / rect.width) * SIZE;
    const svgY = ((event.clientY - rect.top) / rect.height) * SIZE;
    const x = svgX - CENTER;
    const y = svgY - CENTER;
    if (Math.hypot(x, y) < HOVER_DEAD_ZONE) return null;
    let angle = (Math.atan2(y, x) * 180) / Math.PI + 90;
    angle = ((angle % 360) + 360) % 360;
    return normalizeAzimuth(angle);
  };

  const clearHover = () => {
    setHoverAzimuth(null);
    setHoverRotation(null);
    hoverRotationRef.current = null;
  };

  const handleMouseMove = (event: ReactMouseEvent<SVGSVGElement>) => {
    if (!onGoto) { clearHover(); return; }
    const nextAzimuth = azimuthFromMouseEvent(event);
    if (nextAzimuth == null) { clearHover(); return; }
    const nextRotation = unwrapRotation(nextAzimuth, hoverRotationRef.current);
    hoverRotationRef.current = nextRotation;
    setHoverAzimuth(nextAzimuth);
    setHoverRotation(nextRotation);
  };

  const handleDialClick = (event: ReactMouseEvent<SVGSVGElement>) => {
    if (!onGoto) return;
    const requested = azimuthFromMouseEvent(event);
    if (requested == null) return;
    onGoto(requested);
  };

  const tickAngles = Array.from({ length: 72 }, (_, index) => index * 5);
  const degreeLabels = [30, 60, 120, 150, 210, 240, 300, 330];

  const renderPathDot = (angle: number, type: 'sp' | 'lp') => {
    const point = radialPoint(angle, markerRadius(angle));
    return (
      <circle cx={point.x} cy={point.y} r="4.2" fill="currentColor"
        className={type === 'sp' ? 'text-success' : 'text-destructive'}
        opacity={type === 'sp' ? 0.95 : 0.88} pointerEvents="none" />
    );
  };

  const renderTargetDot = (angle: number) => {
    const point = radialPoint(angle, markerRadius(angle));
    return (
      <circle cx={point.x} cy={point.y} r="4.2" fill={TARGET_YELLOW} pointerEvents="none"
        style={{ opacity: targetFading ? 0 : 0.95, transition: `opacity ${TARGET_FADE_MS}ms ease-out` }} />
    );
  };

  // The pointer is drawn once and rotated, so the browser animates the turn
  // instead of the component redrawing a triangle every telemetry read.
  const renderPointer = (rotation: number, colour: string | null, opacity: number, animated: boolean) => {
    const tipY = 88;
    const baseY = 110;
    const innerTipY = 98;
    return (
      <g
        className={colour ? undefined : 'text-success'}
        pointerEvents="none"
        style={{
          transform: `rotate(${rotation}deg)`,
          transformOrigin: `${CENTER}px ${CENTER}px`,
          opacity,
          transition: animated ? 'transform 350ms ease-out' : 'transform 70ms linear, opacity 120ms ease-out',
        }}
      >
        <path d={`M ${CENTER - 13} ${baseY} L ${CENTER} ${tipY} L ${CENTER + 13} ${baseY}`}
          fill="none" stroke={colour ?? 'currentColor'} strokeWidth="5.5"
          strokeLinecap="round" strokeLinejoin="round" />
        <path d={`M ${CENTER - 4.5} ${baseY - 2} L ${CENTER} ${innerTipY} L ${CENTER + 4.5} ${baseY - 2}`}
          fill="none" stroke={colour ?? 'currentColor'} strokeWidth="2"
          strokeLinecap="round" strokeLinejoin="round" opacity="0.6" />
      </g>
    );
  };

  return (
    <div className="w-full h-full min-w-0 aspect-square rounded-md border border-border bg-background flex items-center justify-center overflow-hidden">
      <svg
        viewBox={`0 0 ${SIZE} ${SIZE}`}
        className={cn('block w-full h-full select-none', onGoto ? 'cursor-crosshair' : 'cursor-default')}
        aria-label="Rotor compass"
        onMouseMove={handleMouseMove}
        onMouseLeave={clearHover}
        onClick={handleDialClick}
      >
        <defs>
          <linearGradient id={bgGradientId} x1="0" y1="0" x2="1" y2="1">
            <stop offset="0%" stopColor={MAP_BG_TOP} />
            <stop offset="100%" stopColor={MAP_BG_BOTTOM} />
          </linearGradient>
          {/* The map fades out towards the rim rather than being cut off by a
              circle: at the edge of an azimuthal-equidistant projection the
              antipode is smeared right around the disc, and a hard edge there
              draws the eye to the least meaningful part of the map. */}
          <radialGradient id={mapFadeGradientId} cx="50%" cy="50%" r="50%">
            <stop offset="0%" stopColor="white" />
            <stop offset="72%" stopColor="white" />
            <stop offset="84%" stopColor="#c9c9c9" />
            <stop offset="92%" stopColor="#666666" />
            <stop offset="97%" stopColor="#1a1a1a" />
            <stop offset="100%" stopColor="black" />
          </radialGradient>
          <mask id={mapFadeMaskId}>
            <rect x="0" y="0" width={SIZE} height={SIZE} fill={`url(#${mapFadeGradientId})`} />
          </mask>
        </defs>

        <rect x="0" y="0" width={SIZE} height={SIZE} fill={`url(#${bgGradientId})`} />

        {landPath && (
          <g mask={`url(#${mapFadeMaskId})`} opacity="0.78" pointerEvents="none">
            <path d={landPath} fill={MAP_LAND} />
            <path d={landPath} fill={MAP_LAND_SECONDARY} opacity="0.22" transform="translate(0.35 0.35)" />
          </g>
        )}

        {/* The scale: every 5°, thicker every 10°, 30° and at the cardinals. */}
        <g pointerEvents="none">
          {tickAngles.map((angle) => {
            const cardinal = angle % 90 === 0;
            const major = angle % 30 === 0;
            const medium = !major && angle % 10 === 0;
            const outer = squarePoint(angle, cardinal ? CARDINAL_TICK_OUTER_HALF : SCALE_HALF);
            const inner = squarePoint(angle, major ? MAJOR_TICK_INNER_HALF : medium ? SCALE_HALF - 11 : SCALE_HALF - 6);
            return (
              <line key={`tick-${angle}`} x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y}
                stroke="currentColor" strokeLinecap="round"
                strokeWidth={cardinal ? 4.6 : major ? 3 : medium ? 1.7 : 0.9}
                className={cardinal ? 'text-foreground/95' : major ? 'text-muted-foreground/85'
                  : medium ? 'text-muted-foreground/55' : 'text-muted-foreground/28'} />
            );
          })}
        </g>

        <g pointerEvents="none">
          {degreeLabels.map((angle) => {
            const position = squarePoint(angle, SCALE_HALF + 20);
            return (
              <text key={`degree-${angle}`} x={position.x} y={position.y}
                textAnchor="middle" dominantBaseline="middle" fill="currentColor"
                className="text-[16px] font-mono font-bold text-foreground/90">
                {angle}
              </text>
            );
          })}
        </g>

        <g pointerEvents="none">
          {([['N', CENTER, CARDINAL_LABEL_INSET], ['E', SIZE - CARDINAL_LABEL_INSET, CENTER],
             ['S', CENTER, SIZE - CARDINAL_LABEL_INSET], ['W', CARDINAL_LABEL_INSET, CENTER]] as const).map(
            ([label, x, y]) => (
              <text key={label} x={x} y={y} textAnchor="middle" dominantBaseline="middle"
                fill={COMPASS_ORANGE} className="text-[27px] font-black">
                {label}
              </text>
            ),
          )}
        </g>

        {/* Where the mouse is pointing, in the same shape as the antenna's own
            pointer: the click sends the antenna there, so the preview should
            look like what it will produce. */}
        {hoverAzimuth != null && hoverRotation != null && renderPointer(hoverRotation, COMPASS_ORANGE, 0.78, false)}

        {shortPath != null && renderPathDot(shortPath, 'sp')}
        {longPath != null && renderPathDot(longPath, 'lp')}
        {targetAzimuth != null && renderTargetDot(targetAzimuth)}

        {/* The mechanical boom, dashed and grey, drawn only when the antenna
            radiates somewhere else (Ultrabeam reversed or bidirectional): where
            it POINTS and where it TRANSMITS are then two different answers, and
            the operator needs both. */}
        {boom != null && (() => {
          const tip = radialPoint(boom, POINTER_TIP_RADIUS + 18);
          return (
            <g pointerEvents="none">
              <title>Boom {normalizeAzimuth(boom)}°</title>
              <line x1={CENTER} y1={CENTER} x2={tip.x} y2={tip.y}
                stroke="currentColor" className="text-muted-foreground" strokeWidth="2.5"
                strokeDasharray="5 4" strokeLinecap="round" opacity="0.75" />
            </g>
          );
        })()}

        {/* The second lobe of a bidirectional antenna: same pointer, dimmed. */}
        {secondary != null && renderPointer(normalizeAzimuth(secondary), null, 0.42, true)}
        {azimuth != null && renderPointer(normalizeAzimuth(azimuth), null, 0.96, true)}

        <circle cx={CENTER} cy={CENTER} r={CENTER_DOT_RADIUS} fill={COMPASS_ORANGE} pointerEvents="none" />
      </svg>
    </div>
  );
}

// ── The widget ─────────────────────────────────────────────────────────────

export function RotorCompass({
  bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled,
  rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop,
}: Props) {
  const { t } = useI18n();
  const rotorKey = activeRotor ?? 0;
  const showControls = !!(presets || onStop);

  // The boom is what the ROTOR reports; headings[0] is where the antenna
  // radiates, which is the same thing unless an Ultrabeam is reversed. The dial
  // shows the radiating direction and the figures follow it.
  const rawAzimuth =
    boomHeading != null ? normalizeAzimuth(boomHeading)
      : headings.length > 0 ? normalizeAzimuth(headings[0])
      : null;
  const radiating = headings.length > 0 ? normalizeAzimuth(headings[0]) : rawAzimuth;
  const secondLobe = headings.length > 1 ? normalizeAzimuth(headings[1]) : null;
  const showBoom = boomHeading != null && !!pattern && pattern !== 'normal' ? normalizeAzimuth(boomHeading) : null;

  const [displayAzimuth, setDisplayAzimuth] = useState<number | null>(
    () => (rawAzimuth != null ? rawAzimuth : rememberedAzimuths.get(rotorKey) ?? null),
  );
  // Raw text, not a number: binding the input to a normalised value makes
  // Backspace fight the operator on the way from "230" to "23". It is parsed
  // when it is sent, and only then.
  const [azText, setAzText] = useState('');
  const [flashIdx, setFlashIdx] = useState<number | null>(null);
  const [isMoving, setIsMoving] = useState(() => rememberedTargets.has(rotorKey));
  const [targetAzimuth, setTargetAzimuthState] = useState<number | null>(
    () => rememberedTargets.get(rotorKey) ?? null,
  );
  const [targetFading, setTargetFading] = useState(false);

  const flashTimerRef = useRef<number | undefined>(undefined);
  const movementTimerRef = useRef<number | undefined>(undefined);
  const commandTimerRef = useRef<number | undefined>(undefined);
  const targetArrivalTimerRef = useRef<number | undefined>(undefined);
  const targetFadeTimerRef = useRef<number | undefined>(undefined);
  const latestAzimuthRef = useRef<number | null>(displayAzimuth);
  const movementReferenceRef = useRef<number | null>(displayAzimuth);
  const movementSeenRef = useRef(false);

  const rememberTarget = (value: number | null) => {
    if (value == null) rememberedTargets.delete(rotorKey);
    else rememberedTargets.set(rotorKey, value);
    setTargetAzimuthState(value);
  };

  // Telemetry.
  useEffect(() => {
    if (rawAzimuth == null) return;
    rememberedAzimuths.set(rotorKey, rawAzimuth);
    latestAzimuthRef.current = rawAzimuth;
    setDisplayAzimuth(rawAzimuth);
  }, [rawAzimuth, rotorKey]);

  // Switching rotor: everything on screen belongs to the other one. Its own
  // last reading and its own pending order are restored, and every timer that
  // was counting for the previous rotor is dropped.
  useEffect(() => {
    const rememberedAzimuth = rememberedAzimuths.get(rotorKey);
    const nextAzimuth = rawAzimuth ?? rememberedAzimuth ?? null;
    if (rawAzimuth != null) rememberedAzimuths.set(rotorKey, rawAzimuth);
    setDisplayAzimuth(nextAzimuth);
    latestAzimuthRef.current = nextAzimuth;
    movementReferenceRef.current = nextAzimuth;
    const rememberedTarget = rememberedTargets.get(rotorKey) ?? null;
    setTargetAzimuthState(rememberedTarget);
    setTargetFading(false);
    setIsMoving(rememberedTarget != null);
    movementSeenRef.current = false;
    window.clearTimeout(movementTimerRef.current);
    window.clearTimeout(commandTimerRef.current);
    window.clearTimeout(targetArrivalTimerRef.current);
    window.clearTimeout(targetFadeTimerRef.current);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [rotorKey]);

  useEffect(() => () => {
    window.clearTimeout(flashTimerRef.current);
    window.clearTimeout(movementTimerRef.current);
    window.clearTimeout(commandTimerRef.current);
    window.clearTimeout(targetArrivalTimerRef.current);
    window.clearTimeout(targetFadeTimerRef.current);
  }, []);

  // Movement is inferred from the readout itself, so a rotor turned by its own
  // controller — or by another program — reads as moving here too.
  useEffect(() => {
    if (rawAzimuth == null) return;
    const reference = movementReferenceRef.current;
    if (reference == null) { movementReferenceRef.current = rawAzimuth; return; }
    if (angularDistance(rawAzimuth, reference) >= MOVEMENT_TRIGGER_DEG) {
      movementSeenRef.current = true;
      setIsMoving(true);
      window.clearTimeout(commandTimerRef.current);
      window.clearTimeout(movementTimerRef.current);
      movementTimerRef.current = window.setTimeout(() => {
        setIsMoving(false);
        movementSeenRef.current = false;
      }, MOVEMENT_SETTLE_MS);
      movementReferenceRef.current = rawAzimuth;
    }
  }, [rawAzimuth, rotorKey]);

  // Arrival: confirmed over time, then faded. Every check re-reads the
  // remembered target, so an order given while this was counting cancels it
  // instead of clearing the NEW target on the old one's arrival.
  useEffect(() => {
    if (targetAzimuth == null || rawAzimuth == null || targetFading) {
      window.clearTimeout(targetArrivalTimerRef.current);
      targetArrivalTimerRef.current = undefined;
      return;
    }
    if (angularDistance(rawAzimuth, targetAzimuth) > TARGET_REACHED_DEG) {
      window.clearTimeout(targetArrivalTimerRef.current);
      targetArrivalTimerRef.current = undefined;
      return;
    }
    if (targetArrivalTimerRef.current !== undefined) return;
    const targetSnapshot = targetAzimuth;
    targetArrivalTimerRef.current = window.setTimeout(() => {
      targetArrivalTimerRef.current = undefined;
      const latest = latestAzimuthRef.current;
      if (rememberedTargets.get(rotorKey) !== targetSnapshot) return;
      if (latest == null || angularDistance(latest, targetSnapshot) > TARGET_REACHED_DEG) return;
      setIsMoving(false);
      movementSeenRef.current = false;
      setTargetFading(true);
      window.clearTimeout(movementTimerRef.current);
      window.clearTimeout(commandTimerRef.current);
      window.clearTimeout(targetFadeTimerRef.current);
      targetFadeTimerRef.current = window.setTimeout(() => {
        if (rememberedTargets.get(rotorKey) === targetSnapshot) {
          rememberedTargets.delete(rotorKey);
          setTargetAzimuthState(null);
        }
        setTargetFading(false);
      }, TARGET_FADE_MS);
    }, TARGET_CONFIRM_MS);
  }, [rawAzimuth, targetAzimuth, targetFading, rotorKey]);

  // The rotor went away: nothing more will arrive, so nothing should keep
  // claiming it is turning.
  useEffect(() => {
    if (rotorEnabled === false) {
      movementSeenRef.current = false;
      window.clearTimeout(movementTimerRef.current);
      setIsMoving(false);
    }
  }, [rotorEnabled]);

  // An order that produces no movement at all: the antenna was already there,
  // or nothing is listening. Either way the widget stops saying "turning".
  useEffect(() => {
    if (targetAzimuth == null) return;
    window.clearTimeout(commandTimerRef.current);
    commandTimerRef.current = window.setTimeout(() => {
      if (!movementSeenRef.current) setIsMoving(false);
    }, COMMAND_START_TIMEOUT_MS);
    return () => window.clearTimeout(commandTimerRef.current);
  }, [targetAzimuth, rotorKey]);

  const shortPath = bearing != null ? normalizeAzimuth(bearing) : null;
  const longPath = bearing != null ? normalizeAzimuth(bearing + 180) : null;

  // Six presets to a column — two per row, three rows, level with the dial —
  // and a seventh starts another column rather than making the widget taller.
  // It sits in a row whose height is set by the entry strip: it may grow
  // sideways, never downwards.
  const presetColumns = useMemo(() => {
    const all = presets ?? [];
    const columns: RotorPreset[][] = [];
    for (let i = 0; i < all.length; i += 6) columns.push(all.slice(i, i + 6));
    return columns;
  }, [presets]);

  // 192 dial + 6 gap + 154 controls + 16 padding, plus 60 per preset column.
  const controlsWidth = 154 + presetColumns.length * 60;
  const widgetWidth = 368 + presetColumns.length * 60;

  const markMovementCommanded = () => {
    movementSeenRef.current = false;
    setIsMoving(true);
    movementReferenceRef.current = latestAzimuthRef.current;
    window.clearTimeout(movementTimerRef.current);
    window.clearTimeout(commandTimerRef.current);
  };

  const gotoAzimuth = (azimuth: number) => {
    if (!onGoto) return;
    const target = normalizeAzimuth(azimuth);
    window.clearTimeout(targetArrivalTimerRef.current);
    targetArrivalTimerRef.current = undefined;
    window.clearTimeout(targetFadeTimerRef.current);
    rememberTarget(target);
    setTargetFading(false);
    markMovementCommanded();
    onGoto(target);
  };

  // 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
  // almost always a typo for 36 or 306, and a rotor swinging through north on a
  // slip of the finger is worth one rejected keypress.
  const manualAzimuth = (() => {
    const value = azText.trim();
    if (value === '') return null;
    const n = Number(value);
    if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return null;
    return n;
  })();
  const manualAzimuthValid = manualAzimuth != null && !!onGoto;

  const sendAzimuth = () => {
    if (manualAzimuth == null || !onGoto) return;
    gotoAzimuth(manualAzimuth);
    setAzText('');
  };

  // A rotor takes seconds to start moving and the pointer barely twitches at
  // first, so without this the only answer to "did that register?" is to press
  // it again — which is how an antenna ends up ordered somewhere twice. The
  // acknowledgement has to come from the button itself, at once.
  const pressPreset = (index: number, azimuth: number) => {
    if (!onGoto) return;
    setFlashIdx(index);
    window.clearTimeout(flashTimerRef.current);
    flashTimerRef.current = window.setTimeout(() => setFlashIdx(null), 450);
    gotoAzimuth(azimuth);
  };

  const pressStop = () => {
    if (!onStop) return;
    onStop();
    setIsMoving(false);
    movementSeenRef.current = false;
    movementReferenceRef.current = latestAzimuthRef.current;
    window.clearTimeout(movementTimerRef.current);
    window.clearTimeout(commandTimerRef.current);
    window.clearTimeout(targetArrivalTimerRef.current);
    targetArrivalTimerRef.current = undefined;
    // The order is void, so its marker goes — faded, not snatched away, so the
    // press is visibly what did it.
    if (targetAzimuth != null) {
      const targetSnapshot = targetAzimuth;
      setTargetFading(true);
      window.clearTimeout(targetFadeTimerRef.current);
      targetFadeTimerRef.current = window.setTimeout(() => {
        if (rememberedTargets.get(rotorKey) === targetSnapshot) {
          rememberedTargets.delete(rotorKey);
          setTargetAzimuthState(null);
        }
        setTargetFading(false);
      }, TARGET_FADE_MS);
    }
  };

  const renderPresetButton = (preset: RotorPreset, index: number) => (
    <button
      key={`${preset.label}-${index}`}
      type="button"
      disabled={!onGoto}
      onClick={() => pressPreset(index, preset.azimuth)}
      title={`${preset.label} — ${normalizeAzimuth(preset.azimuth)}°`}
      className={cn(
        'h-full w-full min-h-0 rounded-md border px-0.5 text-[10px] font-bold truncate transition-all duration-150 active:scale-95',
        flashIdx === index
          ? 'border-success bg-success text-success-foreground scale-95'
          : 'border-border bg-muted/40 hover:bg-muted',
        !onGoto && 'opacity-50 cursor-not-allowed',
      )}
    >
      {/* Lit, and showing the azimuth it just sent: the label alone would only
          say the press landed, not what was ordered. */}
      {flashIdx === index ? `${normalizeAzimuth(preset.azimuth)}°` : preset.label}
    </button>
  );

  const renderPresetColumn = (column: RotorPreset[], columnIndex: number) => (
    <div key={`preset-column-${columnIndex}`}
      className="w-[54px] min-w-[54px] shrink-0 grid grid-rows-[60px_60px_60px] gap-1.5 min-h-0">
      {[0, 2, 4].map((row) => (
        <div key={row} className="h-full min-h-0 grid grid-rows-2 gap-1">
          {column[row] && renderPresetButton(column[row], columnIndex * 6 + row)}
          {column[row + 1] && renderPresetButton(column[row + 1], columnIndex * 6 + row + 1)}
        </div>
      ))}
    </div>
  );

  // The SP/LP pair as buttons: the dial carries them as dots, but a dot is a
  // direction and not a number — the same pair sits in the status bar at 10px
  // and operators reported not being able to read it. Part of the controls
  // column, so it appears with the rest of them and never on its own under a
  // dial that has already used up the height.
  const pathButton = (label: 'SP' | 'LP', az: number | null) => (
    <button
      type="button"
      disabled={az == null || !onGoto}
      onClick={() => { if (az != null && onGoto) gotoAzimuth(az); }}
      title={az != null ? `${label === 'SP' ? 'Short Path' : 'Long Path'} ${az}°` : label}
      className={cn(
        'w-full min-w-0 h-full min-h-0 rounded-md border px-0.5 text-center flex flex-col items-center justify-center transition-all overflow-hidden',
        az != null && onGoto
          ? 'border-info-border hover:bg-info-muted active:scale-95'
          : 'border-border opacity-60 cursor-default',
      )}
    >
      <div className="text-[9px] leading-none font-bold text-info-muted-foreground">{label}</div>
      <div className="mt-1 font-mono text-[16px] leading-none font-bold text-info-muted-foreground tabular-nums whitespace-nowrap">
        {az != null ? `${az}°` : '—'}
      </div>
    </button>
  );

  const mainControls = (
    <div className="w-[154px] min-w-[154px] shrink-0 grid grid-rows-[60px_60px_60px] gap-1.5 min-h-0">
      {/* Where the antenna is, and under it — smaller, yellow, and only while it
          matters — where it was told to go. */}
      <div className="h-full min-h-0 rounded-md border border-border bg-background/30 px-1 text-center relative overflow-hidden">
        <div className={cn(
          'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono text-[30px] leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
          targetAzimuth != null ? '-translate-y-[72%]' : '-translate-y-1/2',
          displayAzimuth != null ? 'text-success' : 'text-muted-foreground',
        )}>
          {displayAzimuth != null ? `${displayAzimuth}°` : '—'}
        </div>
        {targetAzimuth != null && (
          <div
            className={cn(
              'absolute left-1/2 top-[39px] -translate-x-1/2 font-mono text-[12px] leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-500 ease-out select-none',
              targetFading ? 'opacity-0 -translate-y-[3px]' : 'opacity-90 translate-y-0',
            )}
            style={{ color: TARGET_YELLOW }}
          >
            {targetAzimuth}°
          </div>
        )}
      </div>

      {/* Free azimuth: Enter sends, so the whole thing is type-three-digits-
          and-go without reaching for the mouse. */}
      <div className={cn('h-full min-h-0 grid gap-1', onStop ? 'grid-rows-2' : 'grid-rows-1')}>
        <div className="h-full min-h-0 grid grid-cols-[minmax(0,1fr)_38px] gap-1">
          <input
            type="text"
            inputMode="numeric"
            value={azText}
            maxLength={3}
            placeholder={t('rotor.azPh')}
            title={t('rotor.azTitle')}
            disabled={!onGoto}
            onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
            onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAzimuth(); } }}
            className="w-full min-w-0 h-full rounded-md border border-border bg-background px-0.5 text-[10px] font-mono tabular-nums text-center disabled:opacity-50 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-warning focus-visible:border-warning transition-colors duration-150"
          />
          <button
            type="button"
            onClick={sendAzimuth}
            disabled={!manualAzimuthValid}
            title={t('rotor.go')}
            className={cn(
              'w-full min-w-0 h-full flex items-center justify-center rounded-md border border-success/60 bg-success-muted px-0.5 text-[10px] font-bold text-success-muted-foreground transition-all active:scale-95',
              manualAzimuthValid ? 'opacity-100 hover:bg-success/25' : 'opacity-40 cursor-not-allowed',
            )}
          >
            {t('rotor.go')}
          </button>
        </div>

        {/* Stop is lit while the antenna is actually turning: it is pressed when
            something is already wrong, and a button that looks inert gets hit
            again and again. */}
        {onStop && (
          <button
            type="button"
            onClick={pressStop}
            title={t('rotor.stop')}
            className={cn(
              'w-full min-w-0 h-full min-h-0 flex items-center justify-center rounded-md border text-[10px] font-bold transition-all duration-150 active:scale-95',
              isMoving
                ? 'border-destructive/80 bg-destructive/25 text-destructive hover:bg-destructive/30'
                : 'border-destructive/60 bg-destructive/15 text-destructive opacity-55 hover:opacity-70',
            )}
          >
            {t('rotor.stop')}
          </button>
        )}
      </div>

      <div className="h-full min-h-0 grid grid-cols-2 gap-1">
        {pathButton('SP', shortPath)}
        {pathButton('LP', longPath)}
      </div>
    </div>
  );

  const dial = (
    <RotorCompassDial
      azimuth={radiating}
      secondary={secondLobe}
      boom={showBoom}
      targetAzimuth={targetAzimuth}
      targetFading={targetFading}
      shortPath={shortPath}
      longPath={longPath}
      centerLat={centerLat}
      centerLon={centerLon}
      onGoto={onGoto ? gotoAzimuth : undefined}
    />
  );

  return (
    <section
      className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden max-w-none shrink-0"
      // Only the full widget fixes its own width — it is a grid of buttons and
      // it knows how many. The dial-only form takes whatever its caller gives.
      style={showControls ? { width: `${widgetWidth}px`, minWidth: `${widgetWidth}px` } : undefined}
    >
      <div className="flex items-center gap-2 px-3 py-1.5 shrink-0">
        <Compass className="size-4 text-primary shrink-0" />
        <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
        <span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-success' : 'bg-muted-foreground/40')}
          title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
        <div className="flex-1" />
        {pattern && (
          <span
            className={cn('px-1.5 py-px rounded text-[9px] font-bold tracking-wide',
              pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
                : pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
                : 'bg-success-muted text-success-muted-foreground')}
            title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
              : pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
              : 'Ultrabeam normal'}>
            {pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
          </span>
        )}
        {/* Without the controls column there is no big readout, so the heading
            goes in the header — the dial-only form is still expected to answer
            "where is it pointing" without being measured by eye. */}
        {!showControls && (
          <span className="font-mono text-sm font-bold text-success tabular-nums">
            {displayAzimuth != null ? `${displayAzimuth.toString().padStart(3, '0')}°` : '—'}
          </span>
        )}
        {onClose && (
          <button type="button" onClick={onClose} title="Hide rotor"
            className="text-muted-foreground hover:text-foreground transition-colors">
            <X className="size-3.5" />
          </button>
        )}
      </div>

      {rotors && rotors.length > 1 && (
        <div className="flex flex-wrap gap-1 px-2 pt-1.5">
          {rotors.map((name, index) => {
            const active = (activeRotor ?? 0) === index;
            const label = name?.trim() || `Rotor ${index + 1}`;
            return (
              <button key={index} type="button" onClick={() => onSelectRotor?.(index)} title={label}
                className={cn(
                  'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
                  active ? 'bg-success text-success-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70',
                )}>
                {label}
              </button>
            );
          })}
        </div>
      )}

      {showControls ? (
        <div className="flex items-stretch gap-1.5 p-2 min-h-0">
          <div className="w-[192px] min-w-[192px] h-[192px] min-h-[192px] shrink-0">{dial}</div>
          <div className="shrink-0 flex gap-1.5 min-h-0"
            style={{ width: `${controlsWidth}px`, minWidth: `${controlsWidth}px` }}>
            {mainControls}
            {presetColumns.map((column, columnIndex) => renderPresetColumn(column, columnIndex))}
          </div>
        </div>
      ) : (
        // THE DIAL AND NOTHING ELSE.
        //
        // The compact form is dropped into a box whose height belongs to the row
        // around it, and a square dial fills that on its own — the SP/LP pair
        // underneath was pushed off the bottom edge. The dots on the dial still
        // carry both paths, and the caller (Station Control, the status bar) has
        // the figures where it wants them.
        <div className="p-2 min-h-0">{dial}</div>
      )}
    </section>
  );
}
