import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
import { writeUiPref } from '@/lib/uiPref';

// BandMap — vertical spectrum panel inspired by Log4OM.
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
//   navigates the band (so 16× lets you read each spot, not crops the
//   band to a 22 kHz slice).
// - Labels sit at their actual frequency by default (straight horizontal
//   leader line). Only when two labels would visually overlap do we
//   bump the lower one down — its leader then becomes diagonal back to
//   the true frequency.

interface Spot {
  source_id?: number;
  source_name?: string;
  dx_call: string;
  freq_khz: number;
  freq_hz: number;
  band?: string;
  comment?: string;
  spotter?: string;
  // The park reference the POTA poller tagged this spot with. The map never
  // reads it — the click handler upstream turns it into the QSO's POTA award
  // reference — but it is declared so it cannot be lost by accident.
  //
  // It travelled only because the spot objects are passed through by reference
  // and the type was erased. The first refactor to copy a spot field by field
  // would have dropped the park silently, and the only symptom would be an
  // activation logged without its reference. Same mistake as the status entry
  // below, whose extra markers were arriving and undeclared.
  pota_ref?: string;
}

// The FULL status entry, not the two fields the map used to declare. The extra
// markers were already arriving in this object — the local type simply never
// mentioned them, so they could not be drawn.
type SpotStatusEntry = {
  status?: string;
  country?: string;
  worked_call?: boolean;
  // worked_slot: this exact callsign already worked on THIS band and mode.
  worked_slot?: boolean;
  new_county?: boolean;
  new_pota?: boolean;
  new_pfx?: boolean;
  // Whether the station uploads to LoTW. Not a status — it says nothing about
  // whether the spot is worth chasing — so it is drawn as a badge, never a colour.
  lotw?: boolean;
};

// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
// entity AND a new park. The cluster list stacks them as separate badges instead
// of letting one replace another, so the map stacks them too — as segments of
// the pill's left accent bar, which until now only repeated the pill's own
// colour and carried no information of its own.
//
// Their colours come from lib/spotMarkers, shared with the cluster list: the
// same fact must not be violet in one panel and green in the next.
// The map shows three of the five. A new PREFIX and a new GRID are left to the
// cluster list, which has the width to name them: the
// pill is 22 px tall, and a fourth segment turns the strip into a colour code
// nobody can read at a glance. Add it here the day the strip earns more room.
const BMP_MARKERS = SPOT_MARKERS.filter((m) => m.key !== 'new_pfx' && m.key !== 'new_grid');
const markersFor = (e: SpotStatusEntry | undefined) =>
  activeMarkers(e).filter((m) => m.key !== 'new_pfx' && m.key !== 'new_grid');

// The legend spells the markers out; the cluster list's badges are abbreviated
// ("NEW CTY") because they sit in a narrow cell, and there is room here.
const BMP_MARKER_LABEL: Record<string, string> = {
  new_pota: 'bmp.legendNewPota',
  new_county: 'bmp.legendNewCounty',
  worked_call: 'bmp.legendWorkedCall',
  // Not on the strip — the pill is 22 px and a fourth segment turns it into a
  // colour code nobody reads — but the TOOLTIP has room, and leaving them out of
  // it made the two panels contradict each other: the cluster said NEW PFX about
  // a spot the map called "Worked". Both were true (the entity is worked on this
  // slot, the WPX prefix never has been) and neither view said so.
  new_pfx: 'clg2.newPfx',
  new_grid: 'clg2.newGrid',
};

interface Props {
  band: string;
  spots: Spot[];
  spotStatus: Record<string, SpotStatusEntry>;
  currentFreqHz: number;
  onSpotClick: (s: Spot) => void;
  onClose?: () => void;
  side?: 'left' | 'right';
  onToggleSide?: () => void;
  // hideDigital drops every DATA-class (FT8/FT4/JS8/…) spot so the crowded
  // watering holes don't hog the map. fitToBand overrides zoom so the whole
  // band edge-to-edge fits the visible height (no scrolling). Both are driven
  // globally from the band-map tab toolbar.
  hideDigital?: boolean;
  fitToBand?: boolean;
  // onToggleFit and onToggleHideDigital add the two switches to this map's own
  // header. Only the docked map passes them: the Band Map tab has both in its
  // toolbar, above maps that all obey them at once, and a switch inside each
  // card as well would be four ways to change one setting.
  onToggleFit?: () => void;
  onToggleHideDigital?: () => void;
  // Mark stations that upload to LoTW (Settings → Appearance).
  showLotw?: boolean;
  // keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
  // frequency (and tune to it). Only the docked Main-view band map sets this, so
  // the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
  keyNav?: boolean;
}

// Band edges and mode segments come from lib/bandplan, which knows the
// operator's IARU region (Settings → General): a Region 2 operator's 40 m runs
// to 7300 with phone from 7125, and drawing Region 1's plan under their spots
// cut the top 100 kHz of the band off the map.
//
// Sub-band shading colours: these are IDENTITIES (which mode the segment is
// for), not states, so they take categorical hues — never the status tokens.
// All three are drawn from the cool end of the categorical order and laid down
// at low opacity, so the band plan stays background context while the warm
// spot pills keep the foreground. Checked with the palette validator in both
// themes (violet failed the dark protan step; magenta clears every check).
const SEG_COLOR: Record<SegMode, string> = {
  cw: 'var(--chart-7)',    // magenta
  digi: 'var(--chart-1)',  // blue
  phone: 'var(--chart-2)', // aqua
};
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
// are read on top of it.
const SEG_OPACITY = 0.13;

// Small coloured dot + label used in the band-map legend strip.
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
  return (
    <span className="inline-flex items-center gap-1">
      <span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
      {label}
    </span>
  );
}

// Human-readable label for a spot status — used in the pill hover tooltip
// so the operator can see WHY a spot is coloured the way it is.
function statusLabel(s: string, t: (k: string) => string): string {
  switch (s) {
    case 'new':      return t('bmp.statusNew');
    case 'new-band-mode': return t('bmp.statusNewBandMode');
    case 'new-band': return t('bmp.statusNewBand');
    case 'new-mode': return t('bmp.statusNewMode');
    case 'new-slot': return t('bmp.statusNewSlot');
    case 'new-call': return t('bmp.statusNewCall');
    case 'worked':   return t('bmp.statusWorked');
    // An empty status means the entity could not be resolved. Nothing else
    // empties it: the mute option leaves the status alone and takes only the
    // already-worked-callsign mark.
    default:         return t('bmp.statusUnresolved');
  }
}

// QUIET: no status colour at all. The pill keeps the card background and the
// accent drops to a plain border grey, so the spot is present but says nothing.
const QUIET_STYLE = {
  pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
  bar:  'bg-muted-foreground/30',
  line: 'stroke-border',
  dot:  'fill-border',
};

function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
  // pill = full pill background+text+border
  // bar  = thick left accent inside the pill
  // line = SVG leader stroke (visible on hover)
  // dot  = small marker on the freq scale
  switch (s) {
    // NEW and NEW BAND+MODE share the strongest colour: both mean "there is
    // something here you have never had", and a sixth shade in a palette read at
    // a glance is a shade nobody can name.
    case 'new':
    case 'new-band-mode': return {
      pill: 'bg-danger-muted text-danger-muted-foreground border-danger-border hover:bg-danger-muted',
      bar:  'bg-danger',
      line: 'stroke-danger',
      dot:  'fill-danger',
    };
    case 'new-band': return {
      pill: 'bg-warning-muted text-warning-muted-foreground border-warning-border hover:bg-warning-muted',
      bar:  'bg-warning',
      line: 'stroke-warning',
      dot:  'fill-warning',
    };
    case 'new-call':
    case 'new-slot': return {
      pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
      bar:  'bg-caution',
      line: 'stroke-caution',
      dot:  'fill-caution',
    };
    case 'worked':   return QUIET_STYLE;
    default:         return {
      pill: 'bg-card text-foreground border-border hover:bg-accent/40',
      bar:  'bg-primary/60',
      line: 'stroke-primary/50',
      dot:  'fill-primary/60',
    };
  }
}

// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
// most bands. Higher levels are for fine inspection of crowded sub-bands.
// Zoom steps, px per kHz. 2 and 4 sit below the old floor of 8 so a whole band
// fits without FIT taking the scale away from you: 20 m end to end is 700 px at
// 2 px/kHz, which scrolls in one screen on most displays.
const PX_PER_KHZ = [2, 4, 8, 16, 32, 64, 128, 256];
const DEFAULT_ZOOM_IDX = PX_PER_KHZ.indexOf(32);

// Zoom is remembered PER BAND, in ONE json key rather than one key per band:
// lib/uiPref keeps an explicit list of the preferences that travel with data/,
// and thirteen entries there to say the same thing would be thirteen chances to
// forget one.
const ZOOM_KEY = 'opslog.bandMapZoom';

function readZoomMap(): Record<string, number> {
  try {
    const m = JSON.parse(localStorage.getItem(ZOOM_KEY) || '{}');
    return m && typeof m === 'object' ? m : {};
  } catch {
    return {};
  }
}

function readZoom(band: string): number {
  const n = readZoomMap()[band];
  return Number.isInteger(n) && n >= 0 && n < PX_PER_KHZ.length ? n : DEFAULT_ZOOM_IDX;
}

function writeZoom(band: string, idx: number): void {
  const m = readZoomMap();
  m[band] = idx;
  writeUiPref(ZOOM_KEY, JSON.stringify(m));
}
const SCALE_W = 56;
const PILL_H = 22;     // px — height of each callsign pill
const PILL_GAP = 32;   // px between scale border and first pill (room for leader)
const LABEL_W = 200;
const TOP_PAD = 14;    // px of breathing room above/below the band edges so
const BOT_PAD = 14;    // the top-most freq label isn't clipped at y=0
// Max DATA-class (digital: FT8/FT4/JS8/RTTY/PSK/…) pills drawn at once.
// These pile up on the watering-hole frequencies and otherwise spawn
// hundreds of spots that fan out and cover the whole map. ONLY digital is
// capped — CW and SSB are always shown in full. When more than this digital
// spots are in band we keep the most useful (new entities first, worked
// last; ties broken by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30;

export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, onToggleFit, onToggleHideDigital, keyNav = false, showLotw = false }: Props) {
  const { t } = useI18n();

  // The two display options are applied ONCE here, on the whole map, so the
  // leader lines, the dots, the pills and the badges can never disagree — and so
  // the map and the cluster list share the single rule set in lib/spotDisplay.
  // Re-derived whenever the poll delivers a new status map, which is also when a
  // just-changed option takes effect.
  // Read at render time, not inside the memo: the options must be part of the
  // dependencies. Keyed only on spotStatusRaw, toggling an option changed
  // nothing until the next poll happened to hand over a fresh object.
  const dispOpts = readSpotDisplayOptions();
  const spotStatus = useMemo(() => {
    if (!dispOpts.muteWorked && !dispOpts.slotHighlight && dispOpts.chasePota) return spotStatusRaw;
    const out: Record<string, SpotStatusEntry> = {};
    for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
    return out;
  }, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight, dispOpts.chasePota]);
  // Re-render when the operator changes their IARU region in Settings.
  const [, setRegionTick] = useState(0);
  useEffect(() => subscribeIaruRegion(() => setRegionTick((n) => n + 1)), []);
  const range = bandRange(band);
  const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
  const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
  // The legend is a reference, not a running display: once its colours are
  // learnt it is four lines of a short screen spent saying nothing new. Folded
  // away by a toggle, and the choice is remembered.
  const [legendOpen, setLegendOpen] = useState(() => {
    try { return localStorage.getItem('opslog.bmpLegend') !== '0'; } catch { return true; }
  });
  const toggleLegend = () => setLegendOpen((v) => {
    const next = !v;
    try { localStorage.setItem('opslog.bmpLegend', next ? '1' : '0'); } catch { /* private mode */ }
    return next;
  });
  // The docked map follows the rig, so a band change must bring up THAT band's
  // remembered zoom.
  useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
  // Stored from the interaction, not from an effect on zoomIdx: an effect would
  // also fire on the band change above, and in the same commit it would still be
  // holding the PREVIOUS band's index — writing it over the new band's. Doing it
  // where the operator actually turns the wheel keeps the two apart. (Running
  // twice under StrictMode is harmless: the same value is stored.)
  const changeZoom = useCallback((delta: number) => {
    setZoomIdx((z) => {
      const n = Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + delta));
      if (n !== z) writeZoom(band, n);
      return n;
    });
  }, [band]);
  const scrollerRef = useRef<HTMLDivElement | null>(null);
  const [containerH, setContainerH] = useState(400);

  useEffect(() => {
    const el = scrollerRef.current;
    if (!el) return;
    const ro = new ResizeObserver(() => setContainerH(el.clientHeight));
    ro.observe(el);
    setContainerH(el.clientHeight);
    return () => ro.disconnect();
  }, []);

  const fallback: [number, number] = range ?? [0, 1];
  const [lo, hi] = fallback;
  const span = hi - lo;
  // Fit-to-band computes the exact px/kHz so the whole band edge-to-edge fills
  // the visible height; otherwise use the discrete zoom step.
  const fitPxPerKHz = fitToBand && containerH > 0 && span > 0
    ? Math.max(0.05, (containerH - TOP_PAD - BOT_PAD) / span)
    : 0;
  const pxPerKHz = fitPxPerKHz || PX_PER_KHZ[zoomIdx];

  // Anti-overlap layout: each label wants to sit at its true freq, but
  // never closer than PILL_H from the previous one. Sorted top-to-bottom
  // (highest freq first). Dedup by callsign (latest wins) so multi-cluster
  // duplicates don't stack identical pills.
  //
  // Returns placed labels + the required content height (the natural
  // band height OR the bottom of the last bumped label, whichever is
  // larger). When more labels stack than fit in the band's natural pixel
  // span, totalH grows so scrolling reveals them.
  type Placed = { spot: Spot; freqY: number; labelY: number };
  const { placed, totalH, hidden } = useMemo<{ placed: Placed[]; totalH: number; hidden: number }>(() => {
    // innerH is the band's stretched pixel span; total adds top+bottom
    // padding so the edge freq labels aren't clipped at y=0 / y=H.
    const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
    if (!range) return { placed: [], totalH: innerH + TOP_PAD + BOT_PAD, hidden: 0 };
    const seen = new Set<string>();
    const inBand: Spot[] = [];
    for (const s of spots) {
      if (s.freq_khz < lo || s.freq_khz > hi) continue;
      if (seen.has(s.dx_call)) continue;
      seen.add(s.dx_call);
      inBand.push(s);
    }

    // Only DATA-class spots (every digital mode — FT8/FT4/JS8/RTTY/PSK/…)
    // are capped — they're what floods the watering-hole frequencies. We key
    // off the mode CATEGORY (not a literal "FT8" string) because many FT8
    // spots carry no mode word and the band-plan fallback labels them the
    // generic "DATA" rather than "FT8". CW and SSB are always shown in full.
    const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA';
    // hideDigital removes them entirely; otherwise they're capped below.
    const ftSpots = hideDigital ? [] : inBand.filter(isFlood);
    const otherSpots = inBand.filter((s) => !isFlood(s));

    // Rank a DATA spot by usefulness (new entity → unworked → worked); ties
    // break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
    const rank = (s: Spot) => {
      const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
      switch (spotStatus[k]?.status ?? '') {
        case 'new':      return 0;
        case 'new-band-mode': return 0;
        case 'new-band': return 1;
        case 'new-slot': return 2;
        case 'new-call': return 2;
        case 'worked':   return 4;
        default:         return 3;
      }
    };
    let keptFt = ftSpots;
    let hiddenCount = 0;
    if (ftSpots.length > MAX_VISIBLE_SPOTS) {
      const rigK = currentFreqHz ? currentFreqHz / 1000 : (lo + hi) / 2;
      keptFt = [...ftSpots]
        .sort((a, b) => {
          const r = rank(a) - rank(b);
          if (r !== 0) return r;
          return Math.abs(a.freq_khz - rigK) - Math.abs(b.freq_khz - rigK);
        })
        .slice(0, MAX_VISIBLE_SPOTS);
      hiddenCount = ftSpots.length - keptFt.length;
    }
    const filtered = [...otherSpots, ...keptFt];
    filtered.sort((a, b) => b.freq_khz - a.freq_khz);

    // Desired pill-CENTRE Y for each spot = its true frequency's Y.
    const desired = filtered.map(
      (s) => TOP_PAD + (1 - (s.freq_khz - lo) / span) * innerH,
    );

    // Non-overlapping label placement via isotonic regression (pool-
    // adjacent-violators). We want centres c_0 ≤ c_1 ≤ … with
    // c_{i+1} − c_i ≥ PILL_H, minimising the squared displacement from each
    // label's desired centre. Substituting q_i = c_i − i·PILL_H turns the
    // gap constraint into "q non-decreasing", which PAVA solves exactly in
    // one pass. The win over the old greedy push-down: a tight cluster is
    // centred on its mean, so its labels fan out symmetrically ABOVE and
    // below the frequency (Log4OM style) instead of all spilling downward.
    const n = filtered.length;
    type Block = { sum: number; count: number; start: number };
    const blocks: Block[] = [];
    for (let i = 0; i < n; i++) {
      // e_i = desired_i − i·PILL_H is the target for the substituted q.
      let cur: Block = { sum: desired[i] - i * PILL_H, count: 1, start: i };
      while (blocks.length > 0) {
        const prev = blocks[blocks.length - 1];
        if (prev.sum / prev.count <= cur.sum / cur.count) break;
        blocks.pop();
        cur = { sum: prev.sum + cur.sum, count: prev.count + cur.count, start: prev.start };
      }
      blocks.push(cur);
    }
    const centers = new Array<number>(n);
    for (const b of blocks) {
      const mean = b.sum / b.count; // optimal q for the whole block
      for (let i = b.start; i < b.start + b.count; i++) centers[i] = mean + i * PILL_H;
    }
    // Centres are non-decreasing, so centers[0] is the topmost. Shift the
    // whole set down by any overflow above the band edge so the first label
    // isn't clipped (preserves the ≥ PILL_H spacing).
    const shift = n > 0 ? Math.max(0, TOP_PAD - (centers[0] - PILL_H / 2)) : 0;

    const out: Placed[] = [];
    for (let i = 0; i < n; i++) {
      out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 });
    }
    const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0;
    // In fit mode the band scale exactly fills the viewport (innerH = containerH
    // − pads); when crowded sub-bands stack labels past the bottom the content
    // grows and a scroll bar appears — the map defaults to the top so the whole
    // band is visible, and you scroll down to reach the stacked spots.
    return {
      placed: out,
      totalH: Math.max(innerH + TOP_PAD + BOT_PAD, lastLabelBottom + BOT_PAD),
      hidden: hiddenCount,
    };
  }, [spots, range, lo, hi, span, pxPerKHz, containerH, spotStatus, currentFreqHz, hideDigital]);

  // freqToY for elements rendered outside the memo (ticks, rig pointer).
  // Must mirror the same offset so the rig triangle sits on the right kHz.
  const innerH = Math.max(containerH - TOP_PAD - BOT_PAD, span * pxPerKHz);
  const freqToY = (kHz: number) => TOP_PAD + (1 - (kHz - lo) / span) * innerH;

  // Keep the map centred on the rig frequency: the effect re-runs only when the
  // frequency actually changes (or band/zoom/size), so tuning follows the rig
  // while a stable frequency leaves your manual scroll alone.
  useEffect(() => {
    if (!range || containerH <= 0) return;
    const el = scrollerRef.current;
    if (!el) return;
    // Fit-to-band: keep the view at the top so the full band is always in frame;
    // any overflow spots are reached by scrolling down (don't follow the rig).
    if (fitToBand) { el.scrollTop = 0; return; }
    if (currentFreqHz <= 0) return;
    const kHz = currentFreqHz / 1000;
    if (kHz < lo || kHz > hi) return;
    el.scrollTop = Math.max(0, freqToY(kHz) - containerH / 2);
    // freqToY is recomputed each render; intentionally excluded from deps.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);

  // No ctrl+wheel zoom here any more: ctrl+wheel is the WINDOW zoom everywhere
  // else in OpsLog (View ▸ Zoom in/out), and one gesture that resizes the whole
  // app over one panel and one band map over another is a gesture nobody can
  // trust. The + / − buttons keep the zoom, deliberately and visibly.

  // Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
  // to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
  // Only active on the docked Main-view map (keyNav) and ignored while typing.
  // MUST stay ABOVE the `if (!range)` early return below: on an unknown band
  // (e.g. a spurious 33 cm CAT reading from an Icom) that return skipped this
  // hook, so the hook count changed between renders and React crashed with #300
  // ("rendered fewer hooks than expected"). lo/hi are always defined (they fall
  // back to [0,1] when there's no range), so it's safe to run here.
  useEffect(() => {
    if (!keyNav) return;
    const onKey = (e: KeyboardEvent) => {
      if (!e.ctrlKey || e.altKey || e.metaKey) return;
      if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
      const ae = document.activeElement as HTMLElement | null;
      const tag = (ae?.tagName || '').toLowerCase();
      if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
      const list = spots
        .filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
        .slice()
        .sort((a, b) => a.freq_hz - b.freq_hz);
      if (!list.length) return;
      const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
      const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
      let target: Spot | undefined;
      if (e.key === 'ArrowUp') {
        target = list.find((s) => s.freq_hz > cur + EPS);
      } else {
        for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
      }
      if (!target) return;
      e.preventDefault();
      onSpotClick(target);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);

  if (!range) {
    return (
      <div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
        <div className="text-sm font-semibold mb-1">{t('bmp.bandMap')}</div>
        {t('bmp.notConfigured', { band: band || '—' })}
      </div>
    );
  }

  // Tick step (where small marks land) and label step (where the kHz
  // number is printed) are decoupled so the scale shows ~10-20 numeric
  // labels per viewport regardless of zoom — at 8 px/kHz we want labels
  // every 25 kHz, not every 250. The bare values below are the floor, and
  // they are what the 2 px/kHz step lands on: a whole band in one screen
  // wants a number every 100 kHz, not every 25.
  let tickStep = 50;
  let labelStep = 100;
  if (pxPerKHz >= 4)   { tickStep = 25; labelStep = 50;  }
  if (pxPerKHz >= 8)   { tickStep = 5;  labelStep = 25;  }
  if (pxPerKHz >= 16)  { tickStep = 2;  labelStep = 10;  }
  if (pxPerKHz >= 32)  { tickStep = 1;  labelStep = 5;   }
  if (pxPerKHz >= 64)  { tickStep = 1;  labelStep = 2;   }
  if (pxPerKHz >= 128) { tickStep = 1;  labelStep = 1;   }
  const ticks: number[] = [];
  for (let t = Math.ceil(lo / tickStep) * tickStep; t <= hi; t += tickStep) ticks.push(t);

  function recenterOnRig() {
    if (!scrollerRef.current || currentFreqHz <= 0) return;
    const y = freqToY(currentFreqHz / 1000);
    scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
  }

  const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
  const showRigPointer = currentKHz >= lo && currentKHz <= hi;
  const rigY = freqToY(currentKHz);

  return (
    <div className="h-full w-full flex flex-col min-h-0 bg-card">
      <div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
        {/* The band alone. "Map · 20M" spent a third of a narrow header saying
            what the panel obviously is, and with four band maps side by side it
            was four times the same word. */}
        <span className="flex-1 min-w-0 truncate">{band}</span>
        <button type="button" onClick={() => changeZoom(-1)} disabled={fitToBand || zoomIdx === 0}
          className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
          title={t('bmp.zoomOut')}>
          <Minus className="size-3" />
        </button>
        {/* A readout, not a switch.
            
            It WAS the switch for a day: clicking the px/kHz value toggled
            fit-to-band, and in fit it read "FIT". Nobody found it — a control
            whose label is a measurement does not look like a control, and once
            switched off there was no longer anything on screen saying the option
            existed. The switches below say what they are. */}
        {/* In fit mode the readout has nothing left to say — the lit FIT button
            says it, and printing the word twice side by side is just noise. The
            Band Map tab's cards have no button, so there the readout keeps
            reporting both states. */}
        {(!fitToBand || !onToggleFit) && (
          <span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
        )}
        <button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
          className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
          title={t('bmp.zoomIn')}>
          <Plus className="size-3" />
        </button>
        {onToggleFit && (
          <button type="button" onClick={onToggleFit} title={t('bmp.fitTitle')}
            className={cn('shrink-0 text-[10px] font-semibold normal-case tracking-normal px-1 rounded border transition-colors',
              fitToBand ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
            {t('bmp.fit')}
          </button>
        )}
        {onToggleHideDigital && (
          <button type="button" onClick={onToggleHideDigital} title={t('bmp.hideFtTitle')}
            className={cn('shrink-0 text-[10px] font-semibold normal-case tracking-normal px-1 rounded border transition-colors',
              hideDigital ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
            {t('bmp.ftx')}
          </button>
        )}
        <button type="button" onClick={recenterOnRig}
          className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
          title={t('bmp.scrollToRig')}>
          <Crosshair className="size-3" />
        </button>
        {onToggleSide && (
          <button type="button" onClick={onToggleSide}
            className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
            title={side === 'right' ? t('bmp.moveLeft') : t('bmp.moveRight')}>
            {side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
          </button>
        )}
        {onClose && (
          <button type="button" onClick={onClose}
            className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
            title={t('bmp.hide')}>
            <X className="size-3" />
          </button>
        )}
      </div>

      <div ref={scrollerRef} className="flex-1 overflow-y-auto overflow-x-hidden relative">
        <div className="relative" style={{ height: totalH, minWidth: SCALE_W + PILL_GAP + LABEL_W }}>
          {/* Scale background segments — stretched to total height */}
          <svg
            className="absolute top-0 left-0 pointer-events-none"
            width={SCALE_W}
            height={totalH}
            preserveAspectRatio="none"
          >
            {segments.map(([s, e, colour], i) => {
              const y1 = freqToY(Math.min(e, hi));
              const y2 = freqToY(Math.max(s, lo));
              return (
                <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)}
                  fill={colour} fillOpacity={SEG_OPACITY} />
              );
            })}
            <line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
          </svg>

          {/* Tick marks + freq labels */}
          {ticks.map((t) => {
            const y = freqToY(t);
            const major = t % labelStep === 0;
            return (
              <div key={t} className="absolute left-0 flex items-center pointer-events-none" style={{ top: y, transform: 'translateY(-50%)', width: SCALE_W }}>
                <div className={cn('border-t', major ? 'w-full border-foreground/40' : 'w-3 border-border/60')} />
                {major && (
                  <span className="absolute left-1 text-[10px] font-mono text-muted-foreground/90 bg-card/80 px-0.5">
                    {t.toLocaleString()}
                  </span>
                )}
              </div>
            );
          })}

          {/* Dots on the scale + leader lines (always-on, subtle) + rig pointer */}
          <svg className="absolute inset-0 pointer-events-none" width="100%" height={totalH} preserveAspectRatio="none">
            {placed.map((p, i) => {
              const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
              const st = spotStatus[k]?.status ?? '';
              const style = statusStyle(st);
              const labelMidY = p.labelY + PILL_H / 2;
              const bumped = Math.abs(p.freqY - labelMidY) > 0.5;
              return (
                <g key={`l-${i}-${p.spot.dx_call}`}>
                  {/* Small dot on the scale where the spot actually is */}
                  <circle cx={SCALE_W - 2} cy={p.freqY} r={3} className={style.dot} />
                  {/* Leader line — solid+full opacity when straight,
                      dashed+lower opacity when diagonal (bumped) so the
                      eye distinguishes "this is the real freq" from
                      "this label was nudged to fit". */}
                  <line
                    x1={SCALE_W + 1}
                    y1={p.freqY}
                    x2={SCALE_W + PILL_GAP - 2}
                    y2={labelMidY}
                    className={cn(style.line, bumped && 'opacity-60')}
                    strokeWidth={bumped ? 1 : 1.5}
                    strokeDasharray={bumped ? '2 2' : ''}
                  />
                </g>
              );
            })}
            {showRigPointer && (
              <>
                {/* Triangle pointer + soft horizontal target line */}
                <polygon
                  points={`${SCALE_W - 6},${rigY - 5} ${SCALE_W + 1},${rigY} ${SCALE_W - 6},${rigY + 5}`}
                  className="fill-primary drop-shadow-sm"
                />
                <line
                  x1={SCALE_W + 1}
                  y1={rigY}
                  x2="100%"
                  y2={rigY}
                  className="stroke-primary/30"
                  strokeWidth={1}
                  strokeDasharray="4 4"
                />
              </>
            )}
          </svg>

          {/* Pills absolutely positioned at their (anti-overlapped) Y */}
          {placed.map((p, i) => {
            const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
            const entry = spotStatus[k];
            const st = entry?.status ?? '';
            const style = statusStyle(st);
            const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
            return (
              <button
                key={`${p.spot.freq_khz}-${p.spot.dx_call}-${i}`}
                type="button"
                onClick={() => onSpotClick(p.spot)}
                style={{ top: p.labelY, left: SCALE_W + PILL_GAP, height: PILL_H }}
                className={cn(
                  'absolute inline-flex items-stretch overflow-hidden rounded-md border shadow-sm cursor-pointer transition-all',
                  'hover:translate-x-0.5 hover:shadow',
                  style.pill,
                )}
                title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${activeMarkers(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
              >
                {/* Left accent strip. With no extra marker it repeats the status
                    colour, exactly as before; otherwise it splits into one
                    segment per marker, so "worked entity + new park" shows both
                    instead of one hiding the other. */}
                {(() => {
                  const marks = markersFor(entry);
                  if (marks.length === 0) return <span className={cn('w-1 shrink-0', style.bar)} aria-hidden />;
                  return (
                    <span className="w-1 shrink-0 flex flex-col" aria-hidden>
                      {marks.map((m) => <span key={m.key} className="flex-1" style={{ background: m.colour }} />)}
                    </span>
                  );
                })()}
                <span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
                  <span>{p.spot.dx_call}</span>
                  {/* Same badge the cluster list draws, for the same reason: the
                      muted-blue of a confirmation, never a status colour —
                      whether a station uploads to LoTW says nothing about
                      whether the spot is worth chasing. A glyph rather than a
                      colour also keeps it off the channel the statuses use. */}
                  {showLotw && entry?.lotw && (
                    <span title={t('clu.lotwBadge')} className="text-[9px] font-normal rounded px-1 py-px"
                      style={{ background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)' }}>L</span>
                  )}
                  {mode && (
                    <span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
                      {mode}
                    </span>
                  )}
                </span>
              </button>
            );
          })}
        </div>
      </div>
      {/* Colour legend — what each pill colour means. */}
      {legendOpen && (
      <div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
        <LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
        <LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
        <LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
        <LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
        {/* The stacked markers, straight from the shared table so the legend
            cannot drift from what the pills actually draw. */}
        {BMP_MARKERS.map((m) => (
          <LegendDot key={m.key} colour={m.colour} label={t(BMP_MARKER_LABEL[m.key])} />
        ))}
        {/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
        <span className="mx-0.5 opacity-40">|</span>
        <LegendDot colour={SEG_COLOR.cw} label={t("bmp.legendCW")} />
        <LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
        <LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
      </div>
      )}
      <div className="px-3 py-1 flex items-center gap-2 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono shrink-0">
        <span className="flex-1 text-center">
          {t('bmp.footerHint')}
          {hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
        </span>
        <button type="button" onClick={toggleLegend}
          title={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
          aria-label={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
          className="shrink-0 inline-flex items-center gap-0.5 rounded px-1 py-px hover:bg-muted hover:text-foreground">
          {legendOpen ? <ChevronDown className="size-3" /> : <ChevronUp className="size-3" />}
        </button>
      </div>
    </div>
  );
}
