import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  AllCommunityModule, ModuleRegistry,
  type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { qslStatusCellClass } from '@/lib/qslStatus';
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX, ListChecks } from 'lucide-react';
import type { QSOForm } from '@/types';
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import {
  Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
import { rowStyleFor, type RowColorSettings } from '@/lib/rowColors';

// Register every Community feature once. v32+ requires explicit registration;
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
// virtual-scroll — everything we want out of the box for a logbook table.
ModuleRegistry.registerModules([AllCommunityModule]);

// Shared theme (follows the app palette); this table runs slightly taller rows.
const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34 });

type Props = {
  rows: QSOForm[];
  total: number;
  // Operator's CURRENT locator — fallback for the distance column on older QSOs
  // that carry no my_grid of their own.
  myGrid?: string;
  // Bump this number to programmatically select every row (e.g. after a search
  // in the QSL Manager, where the default is "all selected").
  selectAllSignal?: number;
  // Bump `seq` to programmatically select the single row with this id (e.g. NET
  // Control auto-advancing to the next on-air station after logging one).
  selectRowSignal?: { id: number; seq: number };
  // Show a row-drag handle on the callsign column (NET Control: drag an on-air
  // row onto the roster to log it). Pair with onGridApi so the parent can
  // register external drop zones via api.addRowDropZone.
  rowDragCall?: boolean;
  // Pass-order mode (NET Control on-air queue): the ROW ORDER GIVEN IN `rows`
  // is the display order — a pinned "#" column numbers it, and all column
  // sorting is disabled (including saved sorts) so the queue can't be shuffled
  // by a header click. The parent owns/reorders the array.
  passOrder?: boolean;
  onGridApi?: (api: any) => void;
  // storageKey scopes the persisted column layout/visibility. Omit for the main
  // log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
  // reuse this grid elsewhere with its OWN independent column config.
  storageKey?: string;
  onRowDoubleClicked?: (q: QSOForm) => void;
  onRowClicked?: (q: QSOForm) => void;
  onRowSelected?: (ids: number[]) => void;
  // The full first selected row — the Stats (F1) panel shows its entity when the
  // entry form is empty, so browsing the log answers "what have I got with this
  // one?" without retyping the call.
  onRowSelectedQso?: (row: QSOForm | null) => void;
  onUpdateFromCty?: (ids: number[]) => void;
  onUpdateFromQRZ?: (ids: number[]) => void;
  onUpdateFromClublog?: (ids: number[]) => void;
  onUpdateCountyFromULS?: (ids: number[]) => void;
  onSendTo?: (service: string, ids: number[]) => void;
  onSendRecording?: (ids: number[]) => void;
  onSendEQSL?: (ids: number[]) => void;
  onBulkEdit?: (ids: number[]) => void;
  onExportSelected?: (ids: number[]) => void;
  onExportSelectedFull?: (ids: number[]) => void;
  onExportSelectedFields?: (ids: number[]) => void;
  onExportFiltered?: () => void;
  onExportFilteredFull?: () => void;
  onExportCabrilloSelected?: (ids: number[]) => void;
  onExportCabrilloFiltered?: () => void;
  onDelete?: (ids: number[]) => void;
  // Reports how many rows the grid shows after its COLUMN filters (the funnel
  // icons), or null when no column filter is active — so the parent's "Showing X
  // of Y" can reflect them. Fired on filter change and when the data updates.
  onFilteredCountChange?: (count: number | null) => void;
  // One column per defined award; the cell shows the reference this QSO counts
  // for (from row.award_refs[CODE], attached by the parent). Hidden by default.
  awardCols?: { code: string; name: string }[];
  // Whole-row colouring by QSL / LoTW status (Settings → Appearance).
  rowColors?: RowColorSettings | null;
};

const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';

function fmtMhzDots(hz?: number): string {
  if (!hz) return '';
  const mhz = (hz / 1_000_000).toFixed(6);
  const [i, f] = mhz.split('.');
  return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
}

// Both formatters follow the display preference (Settings → General). What is
// STORED never changes — see lib/dateFormat.
function fmtDateUTC(s: any): string {
  return formatDateTimeUTC(s);
}
function fmtDateOnly(s: any): string {
  return formatDateOnly(s);
}

// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
// = shown out of the box; anything else stays hidden until the user toggles
// it in the Columns dialog.
export type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };

// Shared so the Worked-before grid (which now also shows full QSO records)
// can offer the exact same column choices without duplicating the catalog.
// A factory taking the i18n `t()` so headerName/label strings are localized;
// hooks can't run at module level, so the component calls this with its own t.
type TFn = (key: string, vars?: Record<string, string | number>) => string;

// qsoDistanceKm returns the great-circle distance for one row, in km.
//
// The QSO's OWN my_grid / my_lat / my_lon come first: a log spans years and
// portable outings, so the station the QSO was made from is not necessarily the
// one configured today. `myGrid` (the current profile) is only the fallback for
// the many older records that carry no my_* fields at all.
function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
  if (!d) return undefined;
  const here =
    (d.my_grid && gridToLatLon(d.my_grid)) ||
    (d.my_lat || d.my_lon ? { lat: d.my_lat || 0, lon: d.my_lon || 0 } : null) ||
    (myGrid ? gridToLatLon(myGrid) : null);
  const there =
    (d.grid && gridToLatLon(d.grid)) ||
    (d.lat || d.lon ? { lat: d.lat || 0, lon: d.lon || 0 } : null);
  if (!here || !there) return undefined;
  return Math.round(pathBetweenLatLon(here, there).distanceShort);
}

export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
  // ── QSO basics ──
  // Position in the log, oldest = 1. Filled by the backend over the WHOLE log,
  // so it does not change when a filter narrows what is shown.
  { group: 'QSO', label: t('rqg.c.number'), colId: 'number', headerName: t('rqg.h.number'), field: 'number' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
    comparator: (a, b) => (a ?? 0) - (b ?? 0) },
  { group: 'QSO', label: t('rqg.c.qso_date'),  colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
  { group: 'QSO', label: t('rqg.c.qso_date_off'),  colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
  { group: 'QSO', label: t('rqg.c.callsign'),  colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
  { group: 'QSO', label: t('rqg.c.band'),      colId: 'band', headerName: t('rqg.c.band'), field: 'band' as any, width: 75, cellClass: 'font-mono', defaultVisible: true },
  { group: 'QSO', label: t('rqg.c.band_rx'),   colId: 'band_rx', headerName: t('rqg.c.band_rx'), field: 'band_rx' as any, width: 75, cellClass: 'font-mono' },
  { group: 'QSO', label: t('rqg.c.mode'),      colId: 'mode', headerName: t('rqg.c.mode'), field: 'mode' as any, width: 80, cellClass: 'font-mono', defaultVisible: true },
  { group: 'QSO', label: t('rqg.c.submode'),   colId: 'submode', headerName: t('rqg.c.submode'), field: 'submode' as any, width: 80, cellClass: 'font-mono' },
  { group: 'QSO', label: t('rqg.c.freq_hz'),  colId: 'freq_hz', headerName: t('rqg.h.freq_hz'), field: 'freq_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
  { group: 'QSO', label: t('rqg.c.freq_rx_hz'),  colId: 'freq_rx_hz', headerName: t('rqg.h.freq_rx_hz'), field: 'freq_rx_hz' as any, width: 110, cellClass: 'font-mono', valueFormatter: (p) => fmtMhzDots(p.value as number | undefined), comparator: (a, b) => (a ?? 0) - (b ?? 0) },
  { group: 'QSO', label: t('rqg.c.rst_sent'),  colId: 'rst_sent', headerName: t('rqg.c.rst_sent'), field: 'rst_sent' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
  { group: 'QSO', label: t('rqg.c.rst_rcvd'),  colId: 'rst_rcvd', headerName: t('rqg.c.rst_rcvd'), field: 'rst_rcvd' as any, width: 85, cellClass: 'font-mono', defaultVisible: true },
  { group: 'QSO', label: t('rqg.c.tx_pwr'),  colId: 'tx_pwr', headerName: t('rqg.c.tx_pwr'), field: 'tx_pwr' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },

  // ── Contacted station ──
  { group: 'Contacted', label: t('rqg.c.name'),     colId: 'name',     headerName: t('rqg.c.name'),     field: 'name' as any,     width: 170, defaultVisible: true },
  { group: 'Contacted', label: t('rqg.c.qth'),      colId: 'qth',      headerName: t('rqg.c.qth'),      field: 'qth' as any,      width: 200, defaultVisible: true },
  { group: 'Contacted', label: t('rqg.c.address'),  colId: 'address',  headerName: t('rqg.c.address'),  field: 'address' as any,  width: 200 },
  { group: 'Contacted', label: t('rqg.c.country'),  colId: 'country',  headerName: t('rqg.c.country'),  field: 'country' as any,  width: 150, defaultVisible: true },
  { group: 'Contacted', label: t('rqg.c.state'),    colId: 'state',    headerName: t('rqg.c.state'),    field: 'state' as any,    width: 80 },
  { group: 'Contacted', label: t('rqg.c.cnty'),   colId: 'cnty',     headerName: t('rqg.c.cnty'),   field: 'cnty' as any,     width: 130 },
  { group: 'Contacted', label: t('rqg.c.cont'),colId: 'cont',     headerName: t('rqg.h.cont'),     field: 'cont' as any,     width: 60 },
  { group: 'Contacted', label: t('rqg.c.grid'),     colId: 'grid',     headerName: t('rqg.c.grid'),     field: 'grid' as any,     width: 85, cellClass: 'font-mono', defaultVisible: true },
  { group: 'Contacted', label: t('rqg.c.gridsquare_ext'), colId: 'gridsquare_ext', headerName: t('rqg.h.gridsquare_ext'), field: 'gridsquare_ext' as any, width: 85, cellClass: 'font-mono' },
  { group: 'Contacted', label: t('rqg.c.vucc_grids'),colId: 'vucc_grids', headerName: t('rqg.h.vucc_grids'),  field: 'vucc_grids' as any, width: 130, cellClass: 'font-mono' },
  { group: 'Contacted', label: t('rqg.c.dxcc'),   colId: 'dxcc',     headerName: t('rqg.c.dxcc'),   field: 'dxcc' as any,     width: 70, type: 'rightAligned', cellClass: 'font-mono' },
  { group: 'Contacted', label: t('rqg.c.cqz'),      colId: 'cqz',      headerName: t('rqg.c.cqz'),      field: 'cqz' as any,      width: 60, type: 'rightAligned', cellClass: 'font-mono' },
  { group: 'Contacted', label: t('rqg.c.ituz'),      colId: 'ituz',     headerName: t('rqg.c.ituz'),      field: 'ituz' as any,     width: 60, type: 'rightAligned', cellClass: 'font-mono' },
  { group: 'Contacted', label: t('rqg.c.iota'),     colId: 'iota',     headerName: t('rqg.c.iota'),     field: 'iota' as any,     width: 80, cellClass: 'font-mono' },
  { group: 'Contacted', label: t('rqg.c.sota_ref'), colId: 'sota_ref', headerName: t('rqg.h.sota_ref'),     field: 'sota_ref' as any, width: 110, cellClass: 'font-mono' },
  { group: 'Contacted', label: t('rqg.c.pota_ref'), colId: 'pota_ref', headerName: t('rqg.h.pota_ref'),     field: 'pota_ref' as any, width: 110, cellClass: 'font-mono' },
  { group: 'Contacted', label: t('rqg.c.age'),      colId: 'age',      headerName: t('rqg.c.age'),      field: 'age' as any,      width: 60, type: 'rightAligned' },
  { group: 'Contacted', label: t('rqg.c.lat'),      colId: 'lat',      headerName: t('rqg.c.lat'),      field: 'lat' as any,      width: 90, type: 'rightAligned', cellClass: 'font-mono' },
  { group: 'Contacted', label: t('rqg.c.lon'),      colId: 'lon',      headerName: t('rqg.c.lon'),      field: 'lon' as any,      width: 90, type: 'rightAligned', cellClass: 'font-mono' },
  // Derived, not stored: computed from the two locations at display time, like
  // the cluster grid's own distance column.
  { group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km',
    headerName: t('rqg.h.distance_km') + ' (' + distanceUnit() + ')', width: 95, type: 'rightAligned', cellClass: 'font-mono',
    valueGetter: (p) => { const km = qsoDistanceKm(p.data, myGrid); return km ? distanceValue(km) : km; },
    comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
  { group: 'Contacted', label: t('rqg.c.email'),    colId: 'email',    headerName: t('rqg.c.email'),    field: 'email' as any,    width: 180 },
  { group: 'Contacted', label: t('rqg.c.web'),      colId: 'web',      headerName: t('rqg.c.web'),      field: 'web' as any,      width: 180 },

  // ── QSL ──
  { group: 'QSL',  label: t('rqg.c.qsl_sent'),     colId: 'qsl_sent',      headerName: t('rqg.c.qsl_sent'),   field: 'qsl_sent' as any,      width: 80 , cellClass: qslStatusCellClass },
  { group: 'QSL',  label: t('rqg.c.qsl_rcvd'),     colId: 'qsl_rcvd',      headerName: t('rqg.c.qsl_rcvd'),   field: 'qsl_rcvd' as any,      width: 80 , cellClass: qslStatusCellClass },
  { group: 'QSL',  label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
  { group: 'QSL',  label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
  { group: 'QSL',  label: t('rqg.c.qsl_via'),      colId: 'qsl_via',       headerName: t('rqg.c.qsl_via'),    field: 'qsl_via' as any,       width: 130 },
  { group: 'QSL',  label: t('rqg.c.qsl_sent_via'), colId: 'qsl_sent_via',  headerName: t('rqg.h.qsl_sent_via'), field: 'qsl_sent_via' as any, width: 110 },
  { group: 'QSL',  label: t('rqg.c.qsl_rcvd_via'), colId: 'qsl_rcvd_via',  headerName: t('rqg.h.qsl_rcvd_via'), field: 'qsl_rcvd_via' as any, width: 110 },
  { group: 'QSL',  label: t('rqg.c.qsl_msg'),      colId: 'qsl_msg',       headerName: t('rqg.c.qsl_msg'),    field: 'qsl_msg' as any,       width: 200 },
  { group: 'QSL',  label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd',   headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },

  // ── LoTW ──
  { group: 'LoTW', label: t('rqg.c.lotw_sent'),      colId: 'lotw_sent',      headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any,      width: 80 , cellClass: qslStatusCellClass },
  { group: 'LoTW', label: t('rqg.c.lotw_rcvd'),      colId: 'lotw_rcvd',      headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any,      width: 80 , cellClass: qslStatusCellClass },
  { group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
  { group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },

  // ── eQSL ──
  { group: 'eQSL', label: t('rqg.c.eqsl_sent'),      colId: 'eqsl_sent',      headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any,      width: 80 , cellClass: qslStatusCellClass },
  { group: 'eQSL', label: t('rqg.c.eqsl_rcvd'),      colId: 'eqsl_rcvd',      headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any,      width: 80 , cellClass: qslStatusCellClass },
  { group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
  { group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
  // App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
  { group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
  // App-specific: operator marked a QSL as RECEIVED for this QSO (drives PSE/TNX).
  { group: 'QSL', label: t('rqg.c.opslog_qsl_card_rcvd'), colId: 'opslog_qsl_card_rcvd', headerName: t('rqg.c.opslog_qsl_card_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_QSL_RCVD'] ? 'Y' : 'N'; }, defaultVisible: false },
  // HAMLOG.online. No promoted column — the ADIF standard names a field for
  // hamlog.EU and none for hamlog.ONLINE — so these read the extras, exactly
  // like the OpsLog card columns above. Hidden by default: a column per
  // service, shown to everyone, is how a grid becomes unreadable.
  { group: 'Uploads', label: t('rqg.c.hamlog_sent'), colId: 'hamlog_sent', headerName: t('rqg.h.hamlog_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMLOG_SENT'] || 'N'; }, defaultVisible: false },
  { group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
  { group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
  { group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
  // HamQTH, the extras again — sent only, the site having no confirmations.
  { group: 'Uploads', label: t('rqg.c.hamqth_sent'), colId: 'hamqth_sent', headerName: t('rqg.h.hamqth_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMQTH_SENT'] || 'N'; }, defaultVisible: false },
  { group: 'Uploads', label: t('rqg.c.hamqth_sent_date'), colId: 'hamqth_sent_date', headerName: t('rqg.h.hamqth_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMQTH_SENT_DATE']), defaultVisible: false },
  // App-specific: when the QSO's audio recording was e-mailed to the station.
  { group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },

  // ── Uploads (online logbooks) ──
  // ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
  // for QRZ only, a "download status/date" (= it came back confirmed). We
  // relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
  // NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
  { group: 'Uploads', label: t('rqg.c.clublog_sent'),      colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'),   field: 'clublog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
  { group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date',   headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any,   width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
  { group: 'Uploads', label: t('rqg.c.hrdlog_sent'),       colId: 'hrdlog_qso_upload_status',  headerName: t('rqg.c.hrdlog_sent'),    field: 'hrdlog_qso_upload_status' as any,  width: 100 , cellClass: qslStatusCellClass },
  { group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'),  colId: 'hrdlog_qso_upload_date',    headerName: t('rqg.h.hrdlog_sent_date'),  field: 'hrdlog_qso_upload_date' as any,    width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
  { group: 'Uploads', label: t('rqg.c.qrz_sent'),      colId: 'qrzcom_qso_upload_status',   headerName: t('rqg.c.qrz_sent'),   field: 'qrzcom_qso_upload_status' as any,   width: 100 , cellClass: qslStatusCellClass },
  { group: 'Uploads', label: t('rqg.c.qrz_rcvd'),      colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'),   field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
  { group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date',     headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any,     width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
  { group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date',   headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any,   width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
  { group: 'Uploads', label: t('rqg.c.clublog_rcvd'),      colId: 'clublog_qso_download_status', headerName: t('rqg.c.clublog_rcvd'),   field: 'clublog_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
  { group: 'Uploads', label: t('rqg.c.clublog_rcvd_date'), colId: 'clublog_qso_download_date',   headerName: t('rqg.h.clublog_rcvd_date'), field: 'clublog_qso_download_date' as any,   width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },

  // ── Contest ──
  { group: 'Contest', label: t('rqg.c.contest_id'),  colId: 'contest_id', headerName: t('rqg.h.contest_id'),     field: 'contest_id' as any, width: 110 },
  { group: 'Contest', label: t('rqg.c.srx'),         colId: 'srx',        headerName: t('rqg.c.srx'),         field: 'srx' as any,        width: 60, type: 'rightAligned' },
  { group: 'Contest', label: t('rqg.c.stx'),         colId: 'stx',        headerName: t('rqg.c.stx'),         field: 'stx' as any,        width: 60, type: 'rightAligned' },
  { group: 'Contest', label: t('rqg.c.srx_string'),  colId: 'srx_string', headerName: t('rqg.h.srx_string'),     field: 'srx_string' as any, width: 100 },
  { group: 'Contest', label: t('rqg.c.stx_string'),  colId: 'stx_string', headerName: t('rqg.h.stx_string'),     field: 'stx_string' as any, width: 100 },
  { group: 'Contest', label: t('rqg.c.check'),       colId: 'check',      headerName: t('rqg.c.check'),       field: 'check' as any,      width: 70 },
  { group: 'Contest', label: t('rqg.c.precedence'),  colId: 'precedence', headerName: t('rqg.c.precedence'),  field: 'precedence' as any, width: 90 },
  { group: 'Contest', label: t('rqg.c.arrl_sect'),colId: 'arrl_sect',  headerName: t('rqg.h.arrl_sect'),   field: 'arrl_sect' as any,  width: 90 },

  // ── Propagation / antenna ──
  { group: 'Propagation', label: t('rqg.c.prop_mode'), colId: 'prop_mode', headerName: t('rqg.h.prop_mode'),     field: 'prop_mode' as any, width: 80 },
  { group: 'Propagation', label: t('rqg.c.sat_name'),  colId: 'sat_name',  headerName: t('rqg.h.sat_name'),      field: 'sat_name' as any,  width: 110 },
  { group: 'Propagation', label: t('rqg.c.sat_mode'),  colId: 'sat_mode',  headerName: t('rqg.c.sat_mode'), field: 'sat_mode' as any,  width: 80 },
  { group: 'Propagation', label: t('rqg.c.ant_az'),    colId: 'ant_az',    headerName: t('rqg.h.ant_az'),       field: 'ant_az' as any,    width: 70, type: 'rightAligned' },
  { group: 'Propagation', label: t('rqg.c.ant_el'),    colId: 'ant_el',    headerName: t('rqg.h.ant_el'),       field: 'ant_el' as any,    width: 70, type: 'rightAligned' },
  { group: 'Propagation', label: t('rqg.c.ant_path'),  colId: 'ant_path',  headerName: t('rqg.h.ant_path'),     field: 'ant_path' as any,  width: 70 },

  // ── My station (operator side) ──
  { group: 'My station', label: t('rqg.c.station_callsign'), colId: 'station_callsign', headerName: t('rqg.h.station_callsign'), field: 'station_callsign' as any, width: 100, cellClass: 'font-mono', defaultVisible: true },
  { group: 'My station', label: t('rqg.c.operator'),     colId: 'operator',         headerName: t('rqg.c.operator'),field: 'operator' as any,         width: 100, cellClass: 'font-mono' },
  { group: 'My station', label: t('rqg.c.my_grid'),      colId: 'my_grid',          headerName: t('rqg.c.my_grid'), field: 'my_grid' as any,          width: 85, cellClass: 'font-mono' },
  { group: 'My station', label: t('rqg.c.my_country'),   colId: 'my_country',       headerName: t('rqg.h.my_country'), field: 'my_country' as any,       width: 130 },
  { group: 'My station', label: t('rqg.c.my_state'),     colId: 'my_state',         headerName: t('rqg.c.my_state'),field: 'my_state' as any,         width: 80 },
  { group: 'My station', label: t('rqg.c.my_cnty'),    colId: 'my_cnty',          headerName: t('rqg.h.my_cnty'), field: 'my_cnty' as any,          width: 110 },
  { group: 'My station', label: t('rqg.c.my_iota'),      colId: 'my_iota',          headerName: t('rqg.c.my_iota'), field: 'my_iota' as any,          width: 80, cellClass: 'font-mono' },
  { group: 'My station', label: t('rqg.c.my_sota'),      colId: 'my_sota_ref',      headerName: t('rqg.c.my_sota'), field: 'my_sota_ref' as any,      width: 110, cellClass: 'font-mono' },
  { group: 'My station', label: t('rqg.c.my_pota'),      colId: 'my_pota_ref',      headerName: t('rqg.c.my_pota'), field: 'my_pota_ref' as any,      width: 110, cellClass: 'font-mono' },
  { group: 'My station', label: t('rqg.c.my_dxcc'),      colId: 'my_dxcc',          headerName: t('rqg.h.my_dxcc'),field: 'my_dxcc' as any,          width: 80, type: 'rightAligned' },
  { group: 'My station', label: t('rqg.c.my_cq_zone'),   colId: 'my_cq_zone',       headerName: t('rqg.h.my_cq_zone'),  field: 'my_cq_zone' as any,       width: 70, type: 'rightAligned' },
  { group: 'My station', label: t('rqg.c.my_itu_zone'),  colId: 'my_itu_zone',      headerName: t('rqg.h.my_itu_zone'),  field: 'my_itu_zone' as any,      width: 70, type: 'rightAligned' },
  { group: 'My station', label: t('rqg.c.my_lat'),       colId: 'my_lat',           headerName: t('rqg.c.my_lat'),  field: 'my_lat' as any,           width: 90, type: 'rightAligned' },
  { group: 'My station', label: t('rqg.c.my_lon'),       colId: 'my_lon',           headerName: t('rqg.c.my_lon'),  field: 'my_lon' as any,           width: 90, type: 'rightAligned' },
  { group: 'My station', label: t('rqg.c.my_street'),    colId: 'my_street',        headerName: t('rqg.h.my_street'),  field: 'my_street' as any,        width: 160 },
  { group: 'My station', label: t('rqg.c.my_city'),      colId: 'my_city',          headerName: t('rqg.h.my_city'),    field: 'my_city' as any,          width: 130 },
  { group: 'My station', label: t('rqg.c.my_zip'),       colId: 'my_postal_code',   headerName: t('rqg.h.my_zip'),     field: 'my_postal_code' as any,   width: 80 },
  { group: 'My station', label: t('rqg.c.my_rig'),       colId: 'my_rig',           headerName: t('rqg.c.my_rig'),  field: 'my_rig' as any,           width: 130 },
  { group: 'My station', label: t('rqg.c.my_antenna'),   colId: 'my_antenna',       headerName: t('rqg.h.my_antenna'),  field: 'my_antenna' as any,       width: 130 },
  { group: 'My station', label: t('rqg.c.my_name'),      colId: 'my_name',          headerName: t('rqg.c.my_name'), field: 'my_name' as any,          width: 120 },
  { group: 'My station', label: t('rqg.c.my_wwff'),      colId: 'my_wwff_ref',      headerName: t('rqg.c.my_wwff'), field: 'my_wwff_ref' as any,      width: 110, cellClass: 'font-mono' },
  { group: 'My station', label: t('rqg.c.my_sig'),       colId: 'my_sig',           headerName: t('rqg.c.my_sig'),  field: 'my_sig' as any,           width: 90 },
  { group: 'My station', label: t('rqg.c.my_sig_info'),  colId: 'my_sig_info',      headerName: t('rqg.c.my_sig_info'), field: 'my_sig_info' as any,    width: 120 },
  { group: 'My station', label: t('rqg.c.my_arrl_sect'), colId: 'my_arrl_sect',     headerName: t('rqg.c.my_arrl_sect'), field: 'my_arrl_sect' as any,  width: 90, cellClass: 'font-mono' },
  { group: 'My station', label: t('rqg.c.my_darc_dok'),  colId: 'my_darc_dok',      headerName: t('rqg.c.my_darc_dok'), field: 'my_darc_dok' as any,    width: 90, cellClass: 'font-mono' },
  { group: 'My station', label: t('rqg.c.my_vucc_grids'),colId: 'my_vucc_grids',    headerName: t('rqg.c.my_vucc_grids'), field: 'my_vucc_grids' as any, width: 130, cellClass: 'font-mono' },

  // ── Misc ──
  { group: 'Misc', label: t('rqg.c.comment'),  colId: 'comment',     headerName: t('rqg.c.comment'),    field: 'comment' as any,     flex: 1, minWidth: 160, defaultVisible: true },
  { group: 'Misc', label: t('rqg.c.notes'),    colId: 'notes',       headerName: t('rqg.c.notes'),      field: 'notes' as any,       width: 240 },
  { group: 'Misc', label: t('rqg.c.created'),  colId: 'created_at',  headerName: t('rqg.h.created'), field: 'created_at' as any,  width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
  { group: 'Misc', label: t('rqg.c.updated'),  colId: 'updated_at',  headerName: t('rqg.h.updated'), field: 'updated_at' as any,  width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
];

export const GROUP_ORDER = [
  'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads',
  'Contest', 'Propagation', 'My station', 'Misc',
];

// Localized display label for a column group (identifier stays English so it
// keeps working as a filter key).
const GRP_KEYS: Record<string, string> = {
  QSO: 'rqg.grpQso', Contacted: 'rqg.grpContacted', QSL: 'rqg.grpQsl',
  LoTW: 'rqg.grpLotw', eQSL: 'rqg.grpEqsl', Uploads: 'rqg.grpUploads',
  Contest: 'rqg.grpContest', Propagation: 'rqg.grpProp',
  'My station': 'rqg.grpMyStation', Misc: 'rqg.grpMisc',
};
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);

// Award-column VISIBILITY is governed SOLELY by the awardShown code-set, never
// by AG Grid's saved column state: dropping `hide` here (on both save and
// restore) stops a stale saved state from re-hiding a shown award column on
// every awardCols rebuild — the desync that made award columns vanish
// mid-session.
//
// Their POSITION, however, has to stay in the state. Filtering the entries out
// entirely left applyColumnState({applyOrder:true}) with no place for them, and
// AG Grid appends what the state doesn't mention — so a column the operator
// dragged to sit AFTER an award column jumped back in front of it on reload.
const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
  (st ?? []).map((s) => {
    if (!String(s?.colId ?? '').startsWith('award_')) return s;
    const { hide: _hide, ...rest } = s as any;
    return rest;
  });

export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportFiltered, onExportFilteredFull, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
  const { t } = useI18n();
  const gridRef = useRef<any>(null);
  const [pickerOpen, setPickerOpen] = useState(false);
  // Column-layout persistence keys — scoped by storageKey so a reused instance
  // (e.g. the Net panel) keeps its own layout independent of the main log.
  const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
  const [menu, setMenu] = useState<QSOMenuState>(null);
  const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
  const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all

  // Localized column catalog — rebuilt when the language changes.
  // Rebuild the columns when the format changes: the formatters are captured
  // inside the column definitions, so nothing else would notice.
  const [dateFmt, setDateFmt] = useState(getDateFormat);
  useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
  const [distUnit, setDistUnit] = useState(distanceUnit);
  useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
  const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt, distUnit]);

  // Right-click: if the clicked row isn't already part of the selection,
  // select just it; then open the bulk-action menu on the whole selection.
  function onCellContextMenu(e: any) {
    const ev = e.event as MouseEvent | undefined;
    ev?.preventDefault();
    const api = gridRef.current?.api;
    if (!api) return;
    if (e.node && !e.node.isSelected()) {
      api.deselectAll();
      e.node.setSelected(true);
    }
    const ids = (api.getSelectedRows() as QSOForm[])
      .map((r) => r.id as number)
      .filter((n) => !!n);
    if (ids.length === 0) return;
    setMenu({ x: ev?.clientX ?? 0, y: ev?.clientY ?? 0, ids });
  }

  // Compute initial column defs: all columns defined, but those not marked
  // defaultVisible start hidden. The user's saved state (loaded onGridReady)
  // overrides this so a previously toggled column wins.
  // While AG Grid rebuilds columns (award columns load async), it fires column
  // events that would otherwise trigger a save of the DEFAULT visibility before
  // we re-apply the user's saved state — clobbering it. This flag suppresses the
  // auto-save during that window. Set in the memo (runs at render, before the
  // column events) and cleared by the re-apply effect below.
  const restoringRef = useRef(true);

  // Award-column visibility is stored as an explicit set of award CODES, NOT via
  // AG Grid's column-state round-trip. Those columns load asynchronously and
  // race the state restore, and AG Grid preserves an existing column's visibility
  // across a columnDefs rebuild instead of re-reading `hide` — which is exactly
  // why previously-shown award columns kept vanishing on reopen. A dedicated set
  // is deterministic: it drives `hide` directly and is re-enforced below.
  const AWARD_SHOWN_KEY = storageKey ? `hamlog.awardColsShown.${storageKey}` : 'hamlog.awardColsShown';
  const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
  useEffect(() => {
    // Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
    if (loadLocal(AWARD_SHOWN_KEY)) return;
    loadRemote(AWARD_SHOWN_KEY).then((remote) => {
      if (remote && remote.length) {
        seedLocal(AWARD_SHOWN_KEY, remote);
        setAwardShown(new Set(remote.map((s: any) => String(s).toUpperCase())));
      }
    });
  }, []);
  const persistAwardShown = useCallback((next: Set<string>) => {
    setAwardShown(next);
    saveState(AWARD_SHOWN_KEY, [...next]);
  }, []);

  // Award-column WIDTHS are persisted separately too, for the same reason as
  // visibility: award columns are stripped from AG Grid's saved column-state
  // round-trip, so their width would otherwise reset to the default on reopen.
  // Stored as an array of { code, width } (code upper-cased); mirrored to the DB.
  const AWARD_WIDTH_KEY = storageKey ? `hamlog.awardColWidths.${storageKey}` : 'hamlog.awardColWidths';
  const awardWidthsRef = useRef<Record<string, number>>({});
  const awardWidthsInit = useRef(false);
  if (!awardWidthsInit.current) {
    awardWidthsInit.current = true;
    for (const it of (loadLocal(AWARD_WIDTH_KEY) ?? []) as any[]) {
      if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
    }
  }
  // Fresh machine: hydrate widths from the portable DB copy, seed the cache, and
  // apply them to any award columns already on screen.
  useEffect(() => {
    if (loadLocal(AWARD_WIDTH_KEY)) return;
    loadRemote(AWARD_WIDTH_KEY).then((remote) => {
      if (!remote || !remote.length) return;
      for (const it of remote as any[]) {
        if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
      }
      seedLocal(AWARD_WIDTH_KEY, remote);
      const api = gridRef.current?.api;
      if (api && awardCols?.length) {
        const ups = awardCols
          .map((a) => ({ key: `award_${a.code}`, newWidth: awardWidthsRef.current[a.code.toUpperCase()] }))
          .filter((u) => !!u.newWidth);
        if (ups.length) api.setColumnWidths(ups);
      }
    });
  }, []);

  const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
    restoringRef.current = true;
    const base = COL_CATALOG.map((c) => {
      const { group: _g, label: _l, defaultVisible, ...rest } = c;
      const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
      if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
        col.rowDrag = true;
      }
      if (passOrder) {
        delete (col as any).sort; // e.g. the date column's default 'desc'
        col.sortable = false;
      }
      return col;
    });
    if (passOrder) {
      base.unshift({
        colId: '__order', headerName: '#', width: 46, pinned: 'left',
        sortable: false, resizable: false, suppressMovable: true, filter: false,
        cellClass: 'font-mono text-muted-foreground tabular-nums',
        valueGetter: (p) => (p.node?.rowIndex ?? 0) + 1,
      } as ColDef<QSOForm>);
    }
    const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
      colId: `award_${a.code}`,
      headerName: a.code,
      headerTooltip: t('rqg.awardTip', { name: a.name }),
      width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110,
      cellClass: 'text-[11px]',
      // Visibility comes from the persisted award-code set, so a column the user
      // showed reappears on reopen and one they didn't stays hidden.
      hide: !awardShown.has(a.code.toUpperCase()),
      valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
    }));
    return [...base, ...awards];
  }, [awardCols, COL_CATALOG, t, awardShown, rowDragCall, passOrder]);

  // Enforce award-column visibility via the API after the columns (re)appear —
  // colDef.hide alone isn't honoured by AG Grid for a column that already exists,
  // so we set it explicitly whenever the award set or the loaded columns change.
  useEffect(() => {
    const api = gridRef.current?.api;
    if (!api || !awardCols?.length) return;
    const widthUps: { key: string; newWidth: number }[] = [];
    for (const a of awardCols) {
      const want = awardShown.has(a.code.toUpperCase());
      const col = api.getColumn(`award_${a.code}`);
      if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
      // Re-apply the saved width — AG Grid keeps an existing column's width across
      // a columnDefs rebuild instead of re-reading colDef.width (same quirk as hide).
      const w = awardWidthsRef.current[a.code.toUpperCase()];
      if (col && w && Math.round(col.getActualWidth()) !== w) widthUps.push({ key: `award_${a.code}`, newWidth: w });
    }
    if (widthUps.length) api.setColumnWidths(widthUps);
  }, [awardCols, awardShown]);

  // No column filters here, deliberately. This grid holds only the most recent
  // page of QSOs, so a column filter searches THOSE rows and nothing else — and
  // an empty result then reads as "the log does not contain it" rather than "it
  // is not on this page". An operator hunting an imported CONTEST_ID across
  // 28 000 QSOs found nothing and reasonably concluded the import had dropped it.
  //
  // The advanced filter queries the whole logbook and is the honest tool for it.
  // Removing the filter also gives each header back the width of its menu icon.
  const defaultColDef = useMemo<ColDef>(() => ({
    sortable: !passOrder,
    resizable: true,
    filter: false,
    suppressMovable: false,
  }), [passOrder]);

  // In pass-order mode a saved sort (from before the mode existed, or synced
  // from elsewhere) must not shuffle the queue — strip sorts when restoring.
  const sanitizeState = useCallback((state: any[]) => (
    passOrder ? state.map((s) => ({ ...s, sort: null })) : state
  ), [passOrder]);

  function onGridReady(e: GridReadyEvent) {
    onGridApi?.(e.api);
    const local = loadLocal(colStateKey);
    if (local) e.api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(local)) as ColumnState[], applyOrder: true });
    // Fall back to the portable DB copy when the local cache is empty
    // (fresh machine / after a reinstall), then re-seed the cache.
    loadRemote(colStateKey).then((remote) => {
      if (remote && !local) {
        e.api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(remote)) as ColumnState[], applyOrder: true });
        seedLocal(colStateKey, remote);
      }
    });
  }
  // Report the post-column-filter row count (funnel filters) to the parent, or
  // null when no column filter is active, so "Showing X of Y" reflects them.
  const reportFilteredCount = useCallback((e: { api?: any }) => {
    const api = e?.api ?? gridRef.current?.api;
    if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
    // Pass-order "#" is a rowIndex-based valueGetter; AG-Grid keeps the same
    // row node across a reorder (getRowId by id), so the number is cached and
    // stale until we force it. Refresh it on every model update (a reorder
    // fires one). force:true bypasses the value cache.
    if (passOrder && api?.refreshCells) {
      api.refreshCells({ columns: ['__order'], force: true });
    }
    if (!api || !onFilteredCountChange) return;
    onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
  }, [onFilteredCountChange, passOrder]);
  const saveColumnState = useCallback(() => {
    if (restoringRef.current) return; // ignore the events fired by a column rebuild
    const state = gridRef.current?.api?.getColumnState();
    if (!state) return;
    saveState(colStateKey, sanitizeAwardCols(state));
    // Award columns are stripped above, so persist their widths on the side.
    let changed = false;
    for (const s of state) {
      const id = String((s as any)?.colId ?? '');
      if (id.startsWith('award_') && (s as any).width) {
        const code = id.slice('award_'.length).toUpperCase();
        if (awardWidthsRef.current[code] !== (s as any).width) {
          awardWidthsRef.current[code] = (s as any).width;
          changed = true;
        }
      }
    }
    if (changed) {
      saveState(AWARD_WIDTH_KEY, Object.entries(awardWidthsRef.current).map(([code, width]) => ({ code, width })));
    }
  }, [colStateKey, AWARD_WIDTH_KEY]);

  // columnDefs is rebuilt whenever the award columns load OR the user toggles an
  // award column (both change the memo → restoringRef flips true at line 316). Each
  // rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
  // saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
  // LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
  // rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
  // an award reset the other columns AND left restoringRef stuck true (saving off).
  useEffect(() => {
    const api = gridRef.current?.api;
    const local = loadLocal(colStateKey);
    if (api && local) api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(local)) as ColumnState[], applyOrder: true });
    // Re-enable saving once AG Grid has settled the column events from the rebuild.
    const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
    return () => window.clearTimeout(t);
    // Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
    // causes here (awardCols/awardShown) missed the others — switching the UI
    // language rebuilds the memo through `t`, which set restoringRef and left it
    // set, so every column change for the rest of the session was silently
    // discarded and the layout reverted on reload. Any future dependency of the
    // memo is now covered for free.
  }, [columnDefs]);

  function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
    if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
  }
  function onSelectionChanged() {
    const api = gridRef.current?.api;
    const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
    setSelCount(sel.length);
    setDispCount(api?.getDisplayedRowCount?.() ?? 0);
    onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
    onRowSelectedQso?.(sel[0] ?? null);
  }
  // Select every row when the caller bumps selectAllSignal (QSL Manager search).
  useEffect(() => {
    if (selectAllSignal === undefined) return;
    gridRef.current?.api?.selectAll();
  }, [selectAllSignal]);

  // Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
  // tick so a row-data refresh in the same render settles first.
  useEffect(() => {
    if (!selectRowSignal || selectRowSignal.seq === 0) return;
    const t = window.setTimeout(() => {
      const api = gridRef.current?.api;
      if (!api) return;
      api.deselectAll();
      api.forEachNode((n: any) => {
        if (n.data?.id === selectRowSignal.id) n.setSelected(true);
      });
    }, 0);
    return () => window.clearTimeout(t);
  }, [selectRowSignal?.seq]);

  // ── Column picker (visibility) ──
  // Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
  // state for "which columns are visible" — AG Grid's column state is the
  // source of truth, and saveColumnState persists it.
  function isColVisible(colId: string): boolean {
    // Award columns: the persisted set is the source of truth (see awardShown).
    if (colId.startsWith('award_')) return awardShown.has(colId.slice('award_'.length).toUpperCase());
    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) {
    // Award columns are driven by the persisted code set — update it and let the
    // enforce effect apply visibility. This is what makes them stick across reopen.
    if (colId.startsWith('award_')) {
      const code = colId.slice('award_'.length).toUpperCase();
      const next = new Set(awardShown);
      if (visible) next.add(code); else next.delete(code);
      persistAwardShown(next);
      gridRef.current?.api?.setColumnsVisible([colId], visible);
      return;
    }
    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();
  }
  // Whole-dialog show/hide. showAll/hideAll cover the CATALOGUE only; the award
  // columns live in their own persisted code set, so a global that skipped them
  // would leave a row of columns behind and look broken.
  function setEverything(visible: boolean) {
    const api = gridRef.current?.api;
    if (!api) return;
    api.setColumnsVisible(COL_CATALOG.map((c) => c.colId!), visible);
    if (awardCols && awardCols.length > 0) {
      persistAwardShown(visible ? new Set(awardCols.map((a) => a.code.toUpperCase())) : new Set());
      api.setColumnsVisible(awardCols.map((a) => `award_${a.code}`), visible);
    }
    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();
    // Award columns are opt-in: reset hides them all.
    persistAwardShown(new Set());
    (awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
  }

  return (
    <>
      <div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
        {/* Live selection count — visible without opening the context menu. */}
        {selCount > 0 && (
          <span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
            {t('rqg.selectedCount', { n: selCount })}
          </span>
        )}
        {/* Select every loaded row that passes the active column filters — so a
            filtered view can be selected in one click (then send to LoTW, bulk
            edit, export…). Once everything is selected the same button flips to
            "Unselect all". */}
        {(() => {
          const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
          return (
            <Button variant="ghost" size="sm" className="h-7 text-[11px]"
              title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
              onClick={() => {
                const api: any = gridRef.current?.api;
                if (!api) return;
                if (allSelected) api.deselectAll();
                else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
                else api.selectAll('filtered');
              }}>
              <ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
            </Button>
          );
        })()}
        <Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
          title={t('rqg.clearFiltersTitle')}>
          <FilterX className="size-3.5" /> {t('rqg.clearFilters')}
        </Button>
        <Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
          <Columns3 className="size-3.5" /> {t('rqg.columns')}
        </Button>
      </div>
      <div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
        <div style={{ position: 'absolute', inset: 0 }}>
          <AgGridReact<QSOForm>
            ref={gridRef}
            theme={hamlogTheme}
            rowData={rows}
            columnDefs={columnDefs}
            defaultColDef={defaultColDef}
            rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
            onGridReady={onGridReady}
            onFilterChanged={reportFilteredCount}
            onModelUpdated={reportFilteredCount}
            onColumnResized={saveColumnState}
            onColumnMoved={saveColumnState}
            onColumnPinned={saveColumnState}
            onColumnVisible={saveColumnState}
            onSortChanged={saveColumnState}
            onRowDoubleClicked={handleRowDoubleClicked}
            onRowClicked={(e) => e.data && onRowClicked?.(e.data)}
            onSelectionChanged={onSelectionChanged}
            onCellContextMenu={onCellContextMenu}
            preventDefaultOnContextMenu
            animateRows={false}
            suppressCellFocus
            getRowId={(p) => String((p.data as any).id)}
            getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null, p.node.rowIndex ?? undefined)}
          />
        </div>
      </div>

      {/* Every handler is passed STRAIGHT through, never wrapped in an arrow: a
          wrapper is always truthy, so the menu drew "Fix country" and "Update
          from the callsign databases" even in a grid that wired neither — and
          clicking them did nothing at all. */}
      <QSOContextMenu
        menu={menu}
        onClose={() => setMenu(null)}
        onUpdateFromCty={onUpdateFromCty}
        onUpdateFromQRZ={onUpdateFromQRZ}
        onUpdateFromClublog={onUpdateFromClublog}
        onUpdateCountyFromULS={onUpdateCountyFromULS}
        onSendTo={onSendTo}
        onSendRecording={onSendRecording}
        onSendEQSL={onSendEQSL}
        onBulkEdit={onBulkEdit}
        onExportSelected={onExportSelected}
        onExportSelectedFull={onExportSelectedFull}
        onExportSelectedFields={onExportSelectedFields}
        onExportFiltered={onExportFiltered}
        onExportFilteredFull={onExportFilteredFull}
        onExportCabrilloSelected={onExportCabrilloSelected}
        onExportCabrilloFiltered={onExportCabrilloFiltered}
        onDelete={onDelete}
      />

      <Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
        <DialogContent className="max-w-3xl">
          <DialogHeader>
            <DialogTitle>{t('rqg.columns')}</DialogTitle>
            <DialogDescription>
              {t('rqg.pickerDesc')}
            </DialogDescription>
          </DialogHeader>
          {/* Whole-dialog controls. The per-group links alone meant hiding
              everything was one click PER GROUP, and there are a dozen. */}
          <div className="flex items-center gap-1 px-5 text-[11px] text-muted-foreground">
            <span>{t('rqg.pickerAllGroups')}</span>
            <button type="button" className="text-[11px] text-primary hover:underline px-1"
              onClick={() => setEverything(true)}>{t('rqg.all')}</button>
            <span className="opacity-40">·</span>
            <button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
              onClick={() => setEverything(false)}>{t('rqg.none')}</button>
          </div>
          <div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
            {GROUP_ORDER.map((group) => {
              // Alphabetical within the group: the catalog's order is the
              // GRID's column order, which appends newcomers at the end — so
              // the two ClubLog rows sat at opposite ends of the Uploads box.
              const cols = COL_CATALOG.filter((c) => c.group === group)
                .slice().sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''));
              if (cols.length === 0) return null;
              return (
                <div key={group} className="rounded-md border border-border p-2.5">
                  <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('rqg.all')}</button>
                      <button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => hideAll(group)}>{t('rqg.none')}</button>
                    </div>
                  </div>
                  <div className="flex flex-col 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>
              );
            })}
            {awardCols && awardCols.length > 0 && (
              <div className="rounded-md border border-border p-2.5">
                <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">{t('rqg.grpAwards')}</span>
                  <div className="flex gap-0.5">
                    <button className="text-[10px] text-primary hover:underline px-1" onClick={() => { persistAwardShown(new Set(awardCols.map((a) => a.code.toUpperCase()))); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], true)); }}>{t('rqg.all')}</button>
                    <button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { persistAwardShown(new Set()); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], false)); }}>{t('rqg.none')}</button>
                  </div>
                </div>
                <div className="flex flex-col gap-1">
                  {awardCols.map((a) => (
                    <label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
                      <Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
                      <span className="font-mono font-semibold">{a.code}</span>
                      <span className="text-muted-foreground truncate">{a.name}</span>
                    </label>
                  ))}
                </div>
              </div>
            )}
          </div>
          <DialogFooter>
            <Button variant="ghost" size="sm" onClick={resetDefaults}>{t('rqg.resetDefaults')}</Button>
            <Button size="sm" onClick={() => setPickerOpen(false)}>{t('rqg.done')}</Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </>
  );
}
