import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  AllCommunityModule, ModuleRegistry,
  type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX } from 'lucide-react';
import {
  Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { markerColour } from '@/lib/spotMarkers';
import { applySpotDisplay, chasePota, chaseSota, readSpotDisplayOptions } from '@/lib/spotDisplay';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
import { useI18n } from '@/lib/i18n';

type TFn = (key: string, vars?: Record<string, string | number>) => string;

ModuleRegistry.registerModules([AllCommunityModule]);

const hamlogTheme = hamlogGridTheme;

export type ClusterSpot = {
  source_id: number;
  source_name: string;
  spotter: string;
  dx_call: string;
  freq_khz: number;
  freq_hz: number;
  band?: string;
  comment?: string;
  locator?: string;
  time_utc?: string;
  country?: string;
  continent?: string;
  cqz?: number;
  ituz?: number;
  distance_km?: number;
  sp_deg?: number;
  lp_deg?: number;
  received_at: string;
  raw: string;
  repeats?: number;
  pota_ref?: string;
  sota_ref?: string;
  pota_name?: string;
};

export type SpotStatusEntry = {
  status?: string;
  country?: string;
  continent?: string;
  worked_call?: boolean;
  // worked_slot: this exact callsign already worked on THIS band and mode.
  // Always slot-scoped, unlike worked_call which follows the "same slot" option.
  worked_slot?: boolean;
  new_county?: boolean;
  // The resolved US county and state, so the grid can SHOW them and not merely
  // flag them. Filled from the offline ULS store, US callsigns only.
  county?: string;
  state?: string;
  new_pota?: boolean;
  new_pfx?: boolean;
  unconf_status?: boolean;
  unconf_pfx?: boolean;
  unconf_cty?: boolean;
  pfx?: string;
  // lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
  lotw?: boolean;
  spotter_continent?: string;
  grid?: string;
  new_grid?: boolean;
  grid_state?: string;
};

type Props = {
  rows: ClusterSpot[];
  spotStatus: Record<string, SpotStatusEntry>;
  // A DOUBLE click works the spot: QSY, mode, callsign, the lot.
  onSpotClick?: (s: ClusterSpot) => void;
  // A SINGLE click only fills the callsign. Reading the cluster means passing
  // over dozens of lines, and every pass used to drag the radio with it — one
  // stray click while looking took the operator off the station they were
  // working. Looking and going are now two different gestures.
  onSpotSelect?: (s: ClusterSpot) => void;
  // Anything the caller wants on the LEFT of the toolbar — the pane's title, its
  // live count, its Filters button. Docked in a pane, the title used to sit on a
  // row of its own above this one, so the cluster ate two lines of a short pane
  // where Recent QSOs beside it ate one. Reported by VK4DX.
  headerLeft?: React.ReactNode;
};

const COL_STATE_KEY = 'hamlog.clusterColState.v1';

// Extracts the prefix from a callsign — drops portable suffixes (/P, /MM
// etc.), keeps a slashed prefix (HB0/DL2SBY → HB0), and trims the trailing
// digits after the last letter group (DL2SBY → DL2).
function fmtPfx(call: string): string {
  if (!call) return '';
  const c = call.trim().toUpperCase();
  const base = c.includes('/') ? c.split('/')[0] : c;
  // If "base" is a callsign rather than a bare prefix (like DL2SBY), cut
  // at the last digit to get DL2.
  let lastDigit = -1;
  for (let i = 0; i < base.length; i++) {
    if (base[i] >= '0' && base[i] <= '9') lastDigit = i;
  }
  return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
}

// Renders an ISO timestamp (RFC3339 with nanoseconds) as a compact UTC
// "YYYY-MM-DD HH:MM:SS" string — matches the rest of the app's date style.
function fmtDateTimeUTC(s: any): string {
  if (!s) return '';
  const d = new Date(s);
  if (isNaN(d.getTime())) return String(s);
  const p = (n: number) => String(n).padStart(2, '0');
  return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
}

type ColEntry = ColDef<ClusterSpot> & { group: string; label: string; defaultVisible?: boolean };

// statusFor resolves the precomputed spot status (new / new-band / new-slot /
// worked-call) for an ag-Grid cell's row.
function statusFor(p: any): SpotStatusEntry | undefined {
  const s = p?.context?.spotStatus?.[
    spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
  ];
  // One chokepoint: the colour, the badges and the Status text all read through
  // here, so the two display options are applied once rather than in each
  // renderer — and the band map applies the very same function.
  return applySpotDisplay(s, readSpotDisplayOptions());
}

// Spot status is shown on the CELL that carries the fact — filled background for
// a novelty, tinted text otherwise — never by a pill or a badge.
//
// The pills were dropped and stay dropped: a rounded box has its own height and
// padding, so it sat off the row's baseline and the callsign inside it no longer
// lined up with the plain callsigns above and below. A whole column of them read
// as chrome rather than as data. Same reasoning — and the same semantic tokens —
// as the Y/N/R QSL columns in lib/qslStatus.ts.
//
// Which cell is marked IS the message:
//
//   filled call → new DXCC    filled band → new band    filled mode → new mode
//   filled pfx  → new prefix  filled POTA → new park    filled county → new county
//   blue call   → already worked (not a novelty, so text only)
const NEW = 'var(--warning)'; // yellow: something here is new
// NEW SLOT gets its own hue. It shared the amber NEW family while the NEW PFX
// marker sits in caution yellow — two different facts, two near-identical
// colours in the same cell. Sky cyan, the exact colour the panadapter palette
// ships for new-slot (#5AC8FA), so the two views tell one story — and clearly
// apart from the POTA green the first attempt (aqua) sat next to.
const NEWSLOT = '#5AC8FA';
const WKD = 'var(--info)';    // blue: this callsign is already in the log

// FILLING the cell that carries the fact, rather than only tinting its text.
//
// This is not a return to the pills that were dropped. A pill is a box INSIDE
// the cell: it has its own height and padding, so it sat off the row baseline
// and the callsign inside it stopped lining up with the plain callsigns above
// and below. A cell background has no geometry of its own — the text does not
// move by a single pixel — and it reads from across the room, which a tinted
// glyph does not.
//
// Which cell is filled is still the whole message: filled Band means new band,
// filled Pfx means new prefix, filled County means new county.
//
// The colours come from the same places they already came from — the semantic
// status tokens and lib/spotMarkers — so a fact keeps one colour across the
// grid, the band map and the filter chips. 22 % is the wash that survived both
// themes: enough to see at a glance, not enough to fight the text on it.
const fillStyle = (colour: string) => ({
  background: `color-mix(in srgb, ${colour} 22%, transparent)`,
  color: colour,
  fontWeight: 700,
});

// cellText renders a cell value in an optional colour. An empty value keeps the
// muted dash the grid used before, so blank cells still read as "nothing here"
// rather than as a gap.
function cellText(value: any, color: string | null): any {
  const txt = value === undefined || value === null || value === '' ? '' : String(value);
  if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
  return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
}

// statusColor is the colour for a resolved status in the Status column. County,
// POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
// DXCC story and an operator filters on them separately.
function statusColor(s: SpotStatusEntry | undefined): string | null {
  switch (s?.status) {
    case 'new':
    case 'new-band-mode':
    case 'new-band':
    case 'new-mode':
    case 'new-call':
      return NEW;
    case 'new-slot':
      return NEWSLOT;
    default:
      return s?.worked_call ? WKD : null;
  }
}

// isDull reports a spot that "represents nothing" under the current worked/slot
// rules: the entity/band/mode is already worked (status resolved, not new in any
// dimension), the callsign itself isn't in the log, and there's no POTA / county
// / prefix novelty. Such rows are dimmed so the eye skips them. Because the
// status obeys the DX-cluster "same slot" option and the digital-mode grouping,
// what counts as dull follows those settings automatically. Unresolved statuses
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
function isDull(s: SpotStatusEntry | undefined): boolean {
  if (!s || !s.status) return false;
  if (s.status === 'new' || s.status === 'new-band-mode' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
  return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx || s.new_grid);
}

const makeColCatalog = (t: TFn): ColEntry[] => [
  {
    group: 'Spot', label: t('clg2.c.time'),  colId: 'time',
    headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
    defaultVisible: true,
    sort: 'desc',
    // Sort by the real arrival timestamp, NOT the "HHMMZ" display string. A
    // lexical sort of time_utc breaks at the UTC day rollover: "0001Z" sorts
    // below "2359Z", so spots received just after midnight fell to the bottom
    // and looked like the cluster had stopped. received_at is a full datetime
    // that keeps ordering correct across 0000Z.
    comparator: (_a: any, _b: any, nodeA: any, nodeB: any) => {
      const ta = Date.parse(nodeA?.data?.received_at ?? '') || 0;
      const tb = Date.parse(nodeB?.data?.received_at ?? '') || 0;
      return ta - tb;
    },
    cellStyle: { color: 'var(--muted-foreground)' },
  },
  {
    group: 'Spot', label: t('clg2.c.call'),  colId: 'call',
    headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
    defaultVisible: true,
    cellClass: 'font-mono',
    // NEW DXCC fills the call cell. Already worked only tints the text blue:
    // 'worked' is not a novelty, and filling it would wash most of the rows.
    cellStyle: (p: any) => (statusFor(p)?.status === 'new' ? fillStyle('var(--danger)') : null) as any,
    cellRenderer: (p: any) => {
      const s = statusFor(p);
      const color = s?.status === 'new' ? null : s?.worked_call ? WKD : null;
      return (
        <span style={{ color: color ?? undefined, fontWeight: 700 }}>
          {p.value ?? ''}
          {/* A single letter rather than a word: the column is 120 px and holds a
              callsign. It stays 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. */}
          {s?.lotw ? (
            <span title={t('clu.lotwBadge')} style={{
              marginLeft: 4, padding: '0 3px', borderRadius: 3, fontSize: 9, verticalAlign: 'middle',
              background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)',
            }}>L</span>
          ) : null}
        </span>
      );
    },
    tooltipValueGetter: (p: any) => {
      const s = statusFor(p);
      return s?.status === 'new' ? t('clg2.tipNewDxcc', { country: s?.country ?? '' }) : s?.worked_call ? t('clg2.tipWorkedCall') : undefined;
    },
  },
  {
    group: 'Spot', label: t('clg2.c.status'), colId: 'status',
    headerName: t('clg2.c.status'), width: 120, sortable: true,
    defaultVisible: true,
    // Spells the status out in words so NEW SLOT (and the others) is obvious at
    // the row level, not just a single coloured cell — NEW SLOT in particular
    // colours no cell at all, since neither the band nor the mode is new on its
    // own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it.
    valueGetter: (p: any) => {
      const s = statusFor(p);
      const parts: string[] = [];
      if (s?.status === 'new') parts.push(t('clg2.newDxcc'));
      else if (s?.status === 'new-band-mode') parts.push(t('clg2.newBandMode'));
      else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
      else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
      else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
      else if (s?.status === 'new-call') parts.push(t('clg2.newCall'));
      else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
      if (s?.new_county) parts.push(t('clg2.newCounty'));
      if (s?.new_pota) parts.push(t('clg2.newPota'));
      if (s?.new_pfx) parts.push(t('clg2.newPfx'));
      if (s?.new_grid) parts.push(s.grid_state === 'unconf' ? t('clg2.newGridUnconf') : t('clg2.newGrid'));
      return parts.join(' ');
    },
    cellRenderer: (p: any) => {
      const s = statusFor(p);
      const parts: { text: string; color: string; dim?: boolean }[] = [];
      const main = statusColor(s);
      if (main) {
        const label = s?.status === 'new' ? t('clg2.newDxcc')
          : s?.status === 'new-band-mode' ? t('clg2.newBandMode')
          : s?.status === 'new-band' ? t('clg2.newBand')
            : s?.status === 'new-mode' ? t('clg2.newMode')
              : s?.status === 'new-slot' ? t('clg2.newSlot')
                : s?.status === 'new-call' ? t('clg2.newCall')
                : t('clg2.wkdCall');
        // Dimmed when the need is only a missing confirmation — the grid's
        // own convention, now spoken by every category.
        parts.push(s?.unconf_status ? { text: label, color: main, dim: true } : { text: label, color: main });
      }
      // Colours from lib/spotMarkers — shared with the band map so a marker is
      // never one colour here and another there.
      if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county'), dim: !!s?.unconf_cty });
      if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
      if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx'), dim: !!s?.unconf_pfx });
      // Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
      // held back, so it reads as "less" of the same thing rather than a
      // different fact — and the label says which.
      if (s?.new_grid) {
        parts.push(s.grid_state === 'unconf'
          ? { text: t('clg2.newGridUnconf'), color: markerColour('new_grid'), dim: true }
          : { text: t('clg2.newGrid'), color: markerColour('new_grid') });
      }
      if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}>—</span>;
      return (
        <span style={{ whiteSpace: 'nowrap' }}>
          {parts.map((pt, i) => (
            <span key={i} style={{ color: pt.color, fontWeight: 700, opacity: pt.dim ? 0.5 : 1 }}>
              {i > 0 ? ' · ' : ''}{pt.text}
            </span>
          ))}
        </span>
      );
    },
    tooltipValueGetter: (p: any) => {
      const s = statusFor(p);
      if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
      if (s?.status === 'new-band-mode') return t('clg2.tipNewBandMode');
      if (s?.status === 'new-band') return t('clg2.tipNewBand');
      if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
      if (s?.status === 'new-call') return t('clg2.tipNewCall');
      if (s?.worked_call) return t('clg2.tipWorkedCall');
      return undefined;
    },
  },
  {
    group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
    headerName: t('clg2.c.pota'), width: 92, cellClass: 'font-mono',
    // Through a valueGetter so the chase switch empties the column live.
    valueGetter: (p: any) => (chasePota() ? p.data?.pota_ref ?? '' : ''),
    defaultVisible: true,
    cellStyle: (p: any) => (statusFor(p)?.new_pota
      ? fillStyle(markerColour('new_pota'))
      : { color: 'var(--success)' }) as any,
    tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
  },
  {
    // SOTA sits next to POTA and reads the same way. The reference comes from the
    // spot's comment, so it is present on the SOTA feeds and empty elsewhere —
    // which is why the column is off by default rather than an empty column for
    // everyone who does not watch summits.
    group: 'Spot', label: t('clg2.c.sota'), colId: 'sota',
    headerName: t('clg2.c.sota'), width: 100, cellClass: 'font-mono',
    valueGetter: (p: any) => (chaseSota() ? p.data?.sota_ref ?? '' : ''),
    defaultVisible: false,
    cellStyle: () => ({ color: 'var(--success)' }) as any,
  },
  {
    group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
    headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
    defaultVisible: true,
    valueFormatter: (p) => typeof p.value === 'number' ? p.value.toFixed(1) : '',
    comparator: (a, b) => (a ?? 0) - (b ?? 0),
  },
  {
    group: 'Spot', label: t('clg2.c.band'), colId: 'band',
    headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
    defaultVisible: true,
    cellClass: 'font-mono',
    // NEW BAND for this entity → the band cell is filled. NEW BAND+MODE fills
    // it too: the band really is new, and leaving it plain would say the
    // opposite of the status column beside it.
    cellStyle: (p: any) => {
      const st = statusFor(p)?.status;
      return (st === 'new-band' || st === 'new-band-mode' ? fillStyle(NEW) : null) as any;
    },
    cellRenderer: (p: any) => cellText(p.value, null),
    tooltipValueGetter: (p: any) => {
      const st = statusFor(p)?.status;
      return st === 'new-band-mode' ? t('clg2.tipNewBandMode') : st === 'new-band' ? t('clg2.tipNewBand') : undefined;
    },
  },
  {
    group: 'Spot', label: t('clg2.c.mode'), colId: 'mode',
    headerName: t('clg2.c.mode'), colSpan: undefined, width: 80,
    defaultVisible: true,
    cellClass: 'font-mono',
    valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
    // NEW MODE and NEW BAND+MODE pill the mode cell — in both the mode itself is
    // genuinely new for the entity. NEW SLOT means band AND mode were each worked
    // before (just not together), so highlighting the mode cell would wrongly
    // imply "CW is new"; that case is signalled by the Status badge alone.
    cellStyle: (p: any) => {
      const st = statusFor(p)?.status;
      return (st === 'new-mode' || st === 'new-band-mode' ? fillStyle('var(--caution)') : null) as any;
    },
    cellRenderer: (p: any) => cellText(p.value, null),
    tooltipValueGetter: (p: any) => {
      const st = statusFor(p)?.status;
      if (st === 'new-band-mode') return t('clg2.tipNewBandMode');
      if (st === 'new-mode') return t('clg2.tipNewMode');
      if (st === 'new-slot') return t('clg2.tipNewSlot');
      return undefined;
    },
  },
  {
    group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
    headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
    valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
    // NEW PFX fills the prefix cell, in the prefix marker's own colour.
    cellStyle: (p: any) => (statusFor(p)?.new_pfx
      ? fillStyle(markerColour('new_pfx'))
      : { color: 'var(--muted-foreground)' }) as any,
    tooltipValueGetter: (p: any) => (statusFor(p)?.new_pfx ? t('clg2.tipNewPfx') : undefined),
  },
  {
    group: 'Geo', label: t('clg2.c.grid'), colId: 'grid',
    headerName: t('clg2.c.grid'), width: 70, cellClass: 'font-mono',
    // Not on the spot: the square a station announced in a CQ that THIS receiver
    // decoded over the WSJT-X link, so it is filled for the digital watering
    // hole being monitored and empty everywhere else. A cluster line carries the
    // spotter's grid at best, never the DX's.
    valueGetter: (p: any) => statusFor(p)?.grid ?? '',
    cellStyle: (p: any) => (statusFor(p)?.new_grid ? fillStyle(markerColour('new_grid')) : null) as any,
    cellRenderer: (p: any) => cellText(p.value, null),
    tooltipValueGetter: (p: any) => (statusFor(p)?.new_grid ? t('clg2.tipNewGrid') : undefined),
  },
  {
    group: 'Geo', label: t('clg2.c.county'), colId: 'county',
    headerName: t('clg2.c.county'), width: 130, cellClass: 'font-mono',
    // Not on the spot: resolved per callsign from the offline ULS store, so it
    // stays empty for non-US calls and until that database is downloaded.
    valueGetter: (p: any) => {
      const s = statusFor(p);
      if (!s?.county) return '';
      return s.state ? s.county + ', ' + s.state : s.county;
    },
    cellStyle: (p: any) => (statusFor(p)?.new_county ? fillStyle(markerColour('new_county')) : null) as any,
    cellRenderer: (p: any) => cellText(p.value, null),
    tooltipValueGetter: (p: any) => (statusFor(p)?.new_county ? t('clg2.tipNewCounty') : undefined),
  },
  {
    group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
    headerName: t('clg2.h.cqz'), field: 'cqz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
    valueFormatter: (p) => p.value ? String(p.value) : '',
  },
  {
    group: 'Geo', label: t('clg2.c.ituz'), colId: 'ituz',
    headerName: t('clg2.h.ituz'), field: 'ituz' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
    valueFormatter: (p) => p.value ? String(p.value) : '',
  },
  {
    group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
    // The header carries the unit, so the cells stay bare numbers and the
    // column still sorts on the km the backend sent — converting the VALUE
    // would sort miles as if they were kilometres either way, but it would
    // also round twice.
    headerName: t('clg2.h.distance_km') + ' (' + distanceUnit() + ')',
    field: 'distance_km' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono',
    valueFormatter: (p) => p.value ? String(distanceValue(p.value)) : '',
    comparator: (a, b) => (a ?? 0) - (b ?? 0),
  },
  {
    group: 'Geo', label: t('clg2.c.sp_deg'), colId: 'sp_deg',
    headerName: t('clg2.h.sp_deg'), field: 'sp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
    valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
    comparator: (a, b) => (a ?? 0) - (b ?? 0),
  },
  {
    group: 'Geo', label: t('clg2.c.lp_deg'), colId: 'lp_deg',
    headerName: t('clg2.h.lp_deg'), field: 'lp_deg' as any, width: 60, type: 'rightAligned', cellClass: 'font-mono',
    valueFormatter: (p) => (p.value || p.value === 0) ? String(p.value) : '',
    comparator: (a, b) => (a ?? 0) - (b ?? 0),
  },
  {
    group: 'Spot', label: t('clg2.c.country'), colId: 'country',
    headerName: t('clg2.c.country'), width: 140,
    defaultVisible: true,
    valueGetter: (p: any) => p.data?.country ?? p.context?.spotStatus?.[
      spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
    ]?.country ?? '',
    cellStyle: { color: 'var(--muted-foreground)' },
  },
  {
    group: 'Spot', label: t('clg2.c.continent'), colId: 'continent',
    headerName: t('clg2.h.continent'), width: 60, cellClass: 'font-mono',
    defaultVisible: true,
    valueGetter: (p: any) => p.data?.continent ?? p.context?.spotStatus?.[
      spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
    ]?.continent ?? '',
    cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
  },
  {
    group: 'Spot', label: t('clg2.c.spotter'), colId: 'spotter',
    headerName: t('clg2.c.spotter'), field: 'spotter' as any, width: 100, cellClass: 'font-mono',
    defaultVisible: true,
    valueFormatter: (p) => cleanSpotter(p.value ?? ''),
    cellStyle: { color: 'var(--muted-foreground)' },
  },
  {
    group: 'Spot', label: t('clg2.c.source'), colId: 'source',
    headerName: t('clg2.c.source'), field: 'source_name' as any, width: 100,
    defaultVisible: true,
    cellStyle: { color: 'var(--muted-foreground)', fontSize: 10 },
  },
  {
    group: 'Spot', label: t('clg2.c.locator'), colId: 'locator',
    headerName: t('clg2.h.locator'), field: 'locator' as any, width: 80, cellClass: 'font-mono',
    cellStyle: { color: 'var(--muted-foreground)' },
  },
  {
    group: 'Spot', label: t('clg2.c.comment'), colId: 'comment',
    headerName: t('clg2.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160,
    defaultVisible: true,
    cellStyle: { color: 'var(--muted-foreground)' },
  },
  {
    group: 'Spot', label: t('clg2.c.received_at'), colId: 'received_at',
    headerName: t('clg2.h.received_at'), field: 'received_at' as any, width: 160, cellClass: 'font-mono',
    valueFormatter: (p) => fmtDateTimeUTC(p.value),
  },
  {
    group: 'Spot', label: t('clg2.c.raw'), colId: 'raw',
    headerName: t('clg2.c.raw'), field: 'raw' as any, width: 300, cellClass: 'font-mono',
  },
];

const GROUP_ORDER = ['Spot', 'Geo'];

const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);

export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect, headerLeft }: Props) {
  const { t } = useI18n();
  const gridRef = useRef<any>(null);
  const [pickerOpen, setPickerOpen] = useState(false);

  // Localized column catalog — rebuilt when the language changes.
  const [distUnit, setDistUnit] = useState(distanceUnit);
  useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
  const COL_CATALOG = useMemo(() => makeColCatalog(t), [t, distUnit]);

  // A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
  // matching column events. Without this guard those events were persisted, so a
  // single language change overwrote the saved cluster layout — in the cache AND
  // in the database, with nothing to restore from.
  const restoringRef = useRef(true);

  const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => {
    restoringRef.current = true;
    return COL_CATALOG.map((c) => {
      const { group: _g, label: _l, defaultVisible, ...rest } = c;
      return { ...rest, hide: !defaultVisible };
    });
  }, [COL_CATALOG]);

  // Re-apply the saved state after every rebuild, then re-enable saving.
  useEffect(() => {
    const api = gridRef.current?.api;
    const local = loadLocal(COL_STATE_KEY);
    if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
    const tm = window.setTimeout(() => { restoringRef.current = false; }, 0);
    return () => window.clearTimeout(tm);
  }, [columnDefs]);

  const defaultColDef = useMemo<ColDef>(() => ({
    sortable: true, resizable: true, filter: true, suppressMovable: false,
    // Dim "represents nothing" spots at the CELL level, via a class that
    // refreshCells re-applies. Doing it with a row STYLE needed redrawRows() on
    // every status update, which re-rendered the whole grid continuously and
    // pegged the CPU on a busy cluster (the 0.23.6 slowdown).
    cellClassRules: { 'opacity-40': (p: any) => isDull(statusFor(p)) },
  }), []);

  // Pass spotStatus through AG Grid's context so cell renderers can look up
  // per-cell highlight without re-rendering the whole grid when the map
  // updates. We refresh cells whose values depend on it after each prop
  // change below.
  const context = useMemo(() => ({ spotStatus }), [spotStatus]);

  // Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
  // Mode cellStyles and the dimmed "represents nothing" class depend on them but
  // the cell VALUE doesn't change, so ag-grid won't re-render on its own — force a
  // refresh so e.g. a worked call turns blue once its status loads.
  //
  // THROTTLED: under an RBN firehose spotStatus updates ~20×/second, and firing a
  // full refreshCells that often is pure churn on a slow PC. Coalesce the bursts
  // into one refresh every 200 ms (still imperceptible) instead of one per update.
  const refreshPending = useRef<number | undefined>(undefined);
  useEffect(() => {
    if (refreshPending.current !== undefined) return; // a refresh is already queued
    refreshPending.current = window.setTimeout(() => {
      refreshPending.current = undefined;
      gridRef.current?.api?.refreshCells({ force: true });
    }, 200);
  }, [spotStatus]);
  useEffect(() => () => { if (refreshPending.current !== undefined) window.clearTimeout(refreshPending.current); }, []);

  // Restore AFTER the profile scope is known — this grid has no key= remount to
  // save it from reading the wrong (unscoped) cache key at first paint.
  async function onGridReady(e: GridReadyEvent) {
    await whenGridPrefsReady();
    const local = loadLocal(COL_STATE_KEY);
    if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
    const remote = await loadRemote(COL_STATE_KEY);
    if (remote && !local) {
      e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
      seedLocal(COL_STATE_KEY, remote);
    }
  }
  const saveColumnState = useCallback(() => {
    if (restoringRef.current) return; // ignore the events fired by a column rebuild
    const state = gridRef.current?.api?.getColumnState();
    if (state) saveState(COL_STATE_KEY, state);
  }, []);

  // HOLDING THE LIST STILL WHILE IT IS BEING READ.
  //
  // A busy evening puts a spot on the list every second or two, each landing at
  // the top and pushing everything below it down. At the top of the list that is
  // exactly right — it is what makes the panel live. A few rows down it makes
  // the panel unusable: the callsign under the pointer has moved by the time the
  // click lands, and the operator ends up chasing the row they wanted.
  //
  // So the grid freezes as soon as it is scrolled away from the top, and says
  // how many spots are waiting. Scrolling back to the top releases it, as does
  // clicking the notice. Nothing is lost — the spots keep arriving in the shared
  // list; this only decides when the GRID is allowed to redraw with them.
  const [held, setHeld] = useState<ClusterSpot[] | null>(null);
  const shown = held ?? rows;

  // How many arrived since the freeze — counted by TIME, not by finding the
  // frozen top row again.
  //
  // Looking for that row was wrong in the ordinary case: a station spotted again
  // REPLACES its row (that is the de-dupe), so the row we froze on disappears
  // from the live list the moment somebody re-spots it — and the count fell
  // through to "everything is new", jumping from 4 to the buffer cap. Reported
  // as "it shows 4, 5 new spots and then 500 all at once".
  //
  // A timestamp survives both the replacement and the ring buffer, which was the
  // reason the length was not used either.
  const spotTime = (r: ClusterSpot) => Date.parse(String((r as any).received_at ?? '')) || 0;
  const waiting = useMemo(() => {
    if (!held || held.length === 0) return 0;
    const since = spotTime(held[0]);
    if (!since) return 0; // no usable timestamp — say nothing rather than a number
    let n = 0;
    for (const r of rows) {
      if (spotTime(r) > since) n++;
    }
    return n;
  }, [held, rows]);

  const onBodyScroll = (e: { top: number }) => {
    const down = e.top > 4; // a pixel or two of overscroll is not "scrolled down"
    if (!down && held) setHeld(null);
    if (down && !held) setHeld(rows);
  };

  const release = () => {
    setHeld(null);
    gridRef.current?.api?.ensureIndexVisible(0, 'top');
  };

  function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
    if (e.data && onSpotSelect) onSpotSelect(e.data);
  }

  function handleRowDoubleClicked(e: RowDoubleClickedEvent<ClusterSpot>) {
    if (e.data && onSpotClick) onSpotClick(e.data);
  }

  function isColVisible(colId: string): boolean {
    const col = gridRef.current?.api?.getColumn(colId);
    return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
  }
  function setColVisible(colId: string, visible: boolean) {
    const api = gridRef.current?.api;
    if (!api) return;
    api.setColumnsVisible([colId], visible);
    saveColumnState();
  }
  function showAll(group?: string) {
    const api = gridRef.current?.api;
    if (!api) return;
    const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
    api.setColumnsVisible(ids, true);
    saveColumnState();
  }
  function hideAll(group?: string) {
    const api = gridRef.current?.api;
    if (!api) return;
    const ids = COL_CATALOG.filter((c) => !group || c.group === group).map((c) => c.colId!);
    api.setColumnsVisible(ids, false);
    saveColumnState();
  }
  function resetDefaults() {
    const api = gridRef.current?.api;
    if (!api) return;
    const visible = COL_CATALOG.filter((c) => c.defaultVisible).map((c) => c.colId!);
    const hidden = COL_CATALOG.filter((c) => !c.defaultVisible).map((c) => c.colId!);
    api.setColumnsVisible(visible, true);
    api.setColumnsVisible(hidden, false);
    saveColumnState();
  }

  return (
    <>
      <div className="flex items-center gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
        {headerLeft}
        <div className="flex-1" />
        <Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
          title={t('clg2.clearFiltersTitle')}>
          <FilterX className="size-3.5" /> {t('clg2.clearFilters')}
        </Button>
        <Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
          <Columns3 className="size-3.5" /> {t('clg2.columns')}
        </Button>
      </div>
      <div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
        <div style={{ position: 'absolute', inset: 0 }}>
          <AgGridReact<ClusterSpot>
            ref={gridRef}
            theme={hamlogTheme}
            rowData={shown}
            columnDefs={columnDefs}
            defaultColDef={defaultColDef}
            context={context}
            onGridReady={onGridReady}
            onColumnResized={saveColumnState}
            onColumnMoved={saveColumnState}
            onColumnPinned={saveColumnState}
            onColumnVisible={saveColumnState}
            onSortChanged={saveColumnState}
            onRowClicked={handleRowClicked}
            onRowDoubleClicked={handleRowDoubleClicked}
            onBodyScroll={onBodyScroll}
            animateRows={false}
            suppressCellFocus
            getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
          />
        </div>
        {/* Only when something is actually waiting: a frozen list with nothing
            new to show needs no announcement. */}
        {waiting > 0 && (
          <button type="button" onClick={release}
            className="absolute left-1/2 -translate-x-1/2 top-1 z-10 rounded-full border border-primary bg-primary px-3 py-1 text-[11px] font-semibold text-primary-foreground shadow-lg hover:opacity-90">
            {t('clg2.newSpots', { n: waiting })}
          </button>
        )}
      </div>

      <Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
        <DialogContent className="max-w-md">
          <DialogHeader>
            <DialogTitle>{t('clg2.pickerTitle')}</DialogTitle>
            <DialogDescription>
              {t('clg2.pickerDesc')}
            </DialogDescription>
          </DialogHeader>
          {/* Whole-dialog controls. showAll/hideAll with no group already mean
              "every column"; only the buttons were missing, so hiding the lot
              took one click per group. */}
          <div className="flex items-center gap-1 px-1 text-[11px] text-muted-foreground">
            <span>{t('clg2.allGroups')}</span>
            <button type="button" className="text-[11px] text-primary hover:underline px-1"
              onClick={() => showAll()}>{t('clg2.all')}</button>
            <span className="opacity-40">·</span>
            <button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
              onClick={() => hideAll()}>{t('clg2.none')}</button>
          </div>
          <div className="max-h-[60vh] overflow-y-auto py-2">
            {GROUP_ORDER.map((group) => {
              const cols = COL_CATALOG.filter((c) => c.group === group);
              if (cols.length === 0) return null;
              return (
                <div key={group} className="rounded-md border border-border p-2.5 mb-2">
                  <div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
                    <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{groupLabel(t, group)}</span>
                    <div className="flex gap-0.5">
                      <button className="text-[10px] text-primary hover:underline px-1" onClick={() => showAll(group)}>{t('clg2.all')}</button>
                      <button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('clg2.none')}</button>
                    </div>
                  </div>
                  <div className="grid grid-cols-2 gap-1">
                    {cols.map((c) => (
                      <label key={c.colId} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
                        <Checkbox
                          checked={isColVisible(c.colId!)}
                          onCheckedChange={(v) => setColVisible(c.colId!, !!v)}
                        />
                        {c.label}
                      </label>
                    ))}
                  </div>
                </div>
              );
            })}
          </div>
          <DialogFooter>
            <Button variant="ghost" size="sm" onClick={resetDefaults}>{t('clg2.resetDefaults')}</Button>
            <Button size="sm" onClick={() => setPickerOpen(false)}>{t('clg2.done')}</Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </>
  );
}
