import { useEffect, useMemo, useState } from 'react';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards, GetTrackedAwards } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { isQSLConfirmed } from '@/lib/qsl';
import { AwardEditor } from '@/components/AwardEditor';
import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';

type BandCount = { band: string; worked: number; confirmed: number };
type AwardRef = {
  ref: string; name?: string; group?: string; subgrp?: string;
  worked: boolean; confirmed: boolean; validated: boolean;
  bands: string[]; confirmed_bands: string[]; validated_bands: string[];
  // Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
  modes?: string[]; confirmed_modes?: string[];
};
type AwardResult = {
  code: string; name: string; dimension: string;
  worked: number; confirmed: number; validated: number; total: number;
  bands: BandCount[]; refs: AwardRef[];
};

type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number };
type AwardStats = { code: string; bands: string[]; rows: AwardStatRow[] };

// Every band the matrix can show, in the order they belong in.
//
// The columns are the intersection of this with what the award covers, so a
// band missing HERE cannot appear at all whatever the award says — which is how
// DDFM, a French award worked well into the microwaves, ended at 70cm with no
// 23cm column and no way to ask for one.
//
// Mirrors bandOrder in internal/award — the same list in frequency order. The
// two are kept apart because they answer different questions (that one sorts an
// award's own band counts, this one lays out columns), but a band added to one
// belongs in the other.
// The DXCC Challenge counts one point per CONFIRMED entity per band, over ten
// bands — and 60 m is NOT one of them. That is the whole of the gap an operator
// notices between OpsLog and LoTW: 3229 here against 3004 there, and 224 of the
// difference is the 60 m column.
//
// Worked out from the confirmed row rather than asked of the backend, because
// that row IS the answer: the Challenge is its sum over these ten bands.
const CHALLENGE_BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];

const ALL_BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m',
  '6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];

// What to show when there is nothing to narrow it down: an award with no band
// list and no contacts yet. The classic set — the whole of ALL_BANDS would be
// twenty-nine columns of mostly nothing.
const GRID_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm'];

// Per-band status for a reference, highest first.
type CellStatus = 'validated' | 'confirmed' | 'worked' | 'none';
function cellStatus(r: AwardRef, band: string): CellStatus {
  if (r.validated_bands?.includes(band)) return 'validated';
  if (r.confirmed_bands?.includes(band)) return 'confirmed';
  if (r.bands?.includes(band)) return 'worked';
  return 'none';
}
// slotsToConfirm counts the band-slots worked with this reference and not yet
// confirmed on any of them — the QSLs still outstanding, one per cell showing W.
//
// It is the difference the Challenge line makes visible in the aggregate (1832
// worked against 1554 confirmed) without saying WHERE it is. Counted over the
// bands actually on screen, so it always adds up to the columns in front of the
// operator rather than to a band set they filtered out.
function slotsToConfirm(r: AwardRef, bands: string[]): number {
  let n = 0;
  for (const b of bands) if (cellStatus(r, b) === 'worked') n++;
  return n;
}

const CELL_STYLE: Record<CellStatus, string> = {
  validated: 'bg-success text-success-foreground',
  confirmed: 'bg-warning text-warning-foreground',
  worked: 'bg-muted-foreground text-background',
  none: '',
};
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' };

function pct(n: number, total: number): number {
  if (total <= 0) return 0;
  return Math.min(100, Math.round((n / total) * 100));
}

// Two-segment progress: confirmed (solid green) over worked (light amber).
function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed: number; total: number }) {
  if (total <= 0) return null;
  return (
    <div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
      <div className="bg-success" style={{ width: `${pct(confirmed, total)}%` }} />
      <div className="bg-warning/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
    </div>
  );
}

// scoped: the award is limited to one or more DXCC entities. Missing-reference
// detection only means anything for those — see the Missing refs button.
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[]; scoped?: boolean };

export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
  onEditQSO?: (id: number) => void;
  onAwardsChanged?: () => void;
  onPaperQSL?: (call: string) => void;
} = {}) {
  const { t } = useI18n();
  const [awardList, setAwardList] = useState<AwardListItem[]>([]);
  // Computed results are cached per award code — each award is scanned only the
  // first time it's selected (or when explicitly rescanned).
  // Keyed by "CODE|MODECLASS": the same award computed for CW and for all modes
  // are different results, and caching them under one key showed the previous
  // mode’s numbers after switching.
  const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
  const [loading, setLoading] = useState(false);
  const [err, setErr] = useState('');
  const [selected, setSelected] = useState<string>('');
  const [refSearch, setRefSearch] = useState('');
  const [editing, setEditing] = useState(false);
  const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
  const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf' | 'slots_notconf'>('all');
  // Mode filter, stacked ON TOP of the status one. "Worked on CW but not
  // confirmed" is two questions at once, and answering only one of them is what
  // sends an operator to a spreadsheet.
  const [modeFilter, setModeFilter] = useState<'all' | 'CW' | 'PHONE' | 'DIGI'>('all');
  const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
  const [showMissing, setShowMissing] = useState(false);
  const [stats, setStats] = useState<AwardStats | null>(null);
  const [statsLoading, setStatsLoading] = useState(false);
  // Bumped by Rescan to force the stats matrix to re-fetch (the selected award
  // didn't change, but the backend snapshot did).
  const [rescanTick, setRescanTick] = useState(0);

  // Lazily fetch the statistics matrix when the Stats view is shown.
  useEffect(() => {
    if (view !== 'stats' || !selected) return;
    setStatsLoading(true);
    GetAwardStats(selected)
      .then((s) => setStats(s as any))
      .catch(() => setStats(null))
      .finally(() => setStatsLoading(false));
  }, [view, selected, rescanTick]);

  // Rescan: drop the backend snapshot (so confirmations from a fresh LoTW/QRZ
  // download are picked up) and the cached results, then recompute everything.
  async function rescan() {
    if (!selected) return;
    setLoading(true);
    try {
      await RescanAwards();
      setByCode({});
      setRescanTick((t) => t + 1);
      await compute(selected, true);
    } catch (e: any) {
      setErr(String(e?.message ?? e));
    } finally {
      setLoading(false);
    }
  }

  // Compute one award (cached). force=true bypasses the cache (Rescan).
  async function compute(code: string, force = false) {
    if (!code) return;
    const key = `${code}|${modeFilter}`;
    if (!force && byCode[key]) { setSelected(code); return; }
    setSelected(code);
    setLoading(true);
    setErr('');
    try {
      const r = (await GetAward(code, modeFilter === 'all' ? '' : modeFilter)) as any as AwardResult;
      setByCode((m) => ({ ...m, [key]: r }));
    } catch (e: any) {
      setErr(String(e?.message ?? e));
    } finally {
      setLoading(false);
    }
  }

  // Load the award list (no QSO scan), then compute only the first award. The
  // list is narrowed to the awards the operator follows (Settings → Awards); an
  // empty follow-set means "show them all" so the tab is never blank.
  async function loadList() {
    try {
      const [defs, tracked] = await Promise.all([
        GetAwardDefs().then((d) => (d ?? []) as any[]),
        GetTrackedAwards().then((t) => (t ?? []) as string[]).catch(() => [] as string[]),
      ]);
      const follow = new Set(tracked);
      let list: AwardListItem[] = defs
        .map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [], scoped: (d.dxcc_filter ?? []).length > 0 }))
        .sort((a, b) => a.code.localeCompare(b.code));
      if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
      setAwardList(list);
      const first = list.find((a) => a.code === selected) ?? list[0];
      if (first) compute(first.code);
    } catch (e: any) {
      setErr(String(e?.message ?? e));
    }
  }
  useEffect(() => { loadList(); }, []);
  // Re-filter when the operator changes their followed awards in Settings.
  useEffect(() => {
    const off = EventsOn('awards:tracked-changed', () => { loadList(); });
    return () => { off(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const current = byCode[`${selected}|${modeFilter}`];
  // Recompute when the mode class changes: the bands, counts and confirmations
  // all describe the selected mode, so they cannot be filtered client-side.
  useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);

  // Bands relevant to the selected award, used by BOTH the grid and the stats
  // matrix so neither is padded with bands nothing was worked on. Rule: the
  // bands this operator HAS contacts on for this award; failing that, the
  // award's own list of permitted bands. Empty set = no basis at all → callers
  // fall back to the classic columns.
  const awardBands = useMemo(() => {
    // The bands with CONTACTS on them come first, whatever the award declares.
    //
    // It used to take the declared list when there was one, which put up a
    // column for every band the award permits — DDFM allows 6m and 70cm, so
    // both stood there empty on a station that has never worked a French
    // department on either. An always-blank column reads as a gap in the log
    // rather than as a band the operator never tried.
    const worked = new Set((current?.bands ?? [])
      .filter((b) => (b.worked ?? 0) > 0)
      .map((b) => String(b.band).toLowerCase()));
    if (worked.size > 0) return worked;
    // Nothing worked yet: the award's own list is the honest answer — it says
    // what this award is played on, which is the only thing there is to show.
    return new Set((awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase()));
  }, [awardList, selected, current]);

  const gridBands = useMemo(() => {
    if (awardBands.size === 0) return GRID_BANDS;
    // Filtered from ALL_BANDS, so a band the award covers gets its column even
    // when it is one the classic set never listed.
    const filtered = ALL_BANDS.filter((b) => awardBands.has(b));
    return filtered.length ? filtered : GRID_BANDS;
  }, [awardBands]);

  const challenge = useMemo(() => {
    if (!stats || selected.toUpperCase() !== 'DXCC') return null;
    const row = stats.rows.find((r) => r.label === 'CONFIRMED');
    if (!row) return null;
    let total = 0;
    stats.bands.forEach((b, i) => {
      if (CHALLENGE_BANDS.includes(String(b).toLowerCase())) total += Number(row.cells[i]) || 0;
    });
    return total;
  }, [stats, selected]);

  // Stats rows to show: drop the mode-category rows (CW / DIGITAL / PHONE) the
  // award doesn't cover. The "ALL" rows (no suffix) always show; a category row
  // shows only when the award has no emission restriction or lists that emission.
  const statsRows = useMemo(() => {
    if (!stats) return [];
    const em = new Set((awardList.find((a) => a.code === selected)?.emission ?? []).map((e) => e.toUpperCase()));
    if (em.size === 0) return stats.rows;
    return stats.rows.filter((r) => {
      const m = String(r.label).toUpperCase().match(/\b(CW|DIGITAL|PHONE)$/);
      return !m || em.has(m[1]);
    });
  }, [stats, awardList, selected]);

  // Indices into stats.bands to display (and to slice each row's cells by), same
  // band basis as the grid.
  const statsBandIdx = useMemo(() => {
    if (!stats) return [] as number[];
    const all = stats.bands.map((_, i) => i);
    if (awardBands.size === 0) return all;
    const idx = stats.bands
      .map((b, i) => ({ b: b.toLowerCase(), i }))
      .filter((x) => awardBands.has(x.b))
      .map((x) => x.i);
    return idx.length ? idx : all;
  }, [stats, awardBands]);

  // Sort order for the reference table. Reference is the default because that
  // is how award lists are published and how an operator reads a paper list;
  // description answers the other question — "have I worked anything in
  // Alicante" — which reference order scatters across the whole table.
  // Remembered through writeUiPref rather than localStorage, so the choice
  // travels with a copied data/ folder like every other UI preference here.
  const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
    () => {
      const v = localStorage.getItem('opslog.awardRefSort');
      return v === 'name' || v === 'group' ? v : 'ref';
    });
  const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
    () => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
  const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
    if (k === refSort) {
      const d = refSortDir === 'asc' ? 'desc' : 'asc';
      setRefSortDir(d);
      writeUiPref('opslog.awardRefSortDir', d);
    } else {
      setRefSort(k);
      setRefSortDir('asc');
      writeUiPref('opslog.awardRefSort', k);
      writeUiPref('opslog.awardRefSortDir', 'asc');
    }
  };

  const filteredRefs = useMemo(() => {
    if (!current) return [];
    const q = refSearch.trim().toUpperCase();
    const rows = (current.refs ?? []).filter((r) => {
      if (refFilter === 'worked' && !r.worked) return false;
      if (refFilter === 'notworked' && r.worked) return false;
      if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
      // Worked-not-confirmed by SLOT, not by reference: an entity confirmed on
      // 20 m still has a 15 m contact waiting for its card, and every filter
      // above answers "no" for it because the entity itself is confirmed.
      if (refFilter === 'slots_notconf' && slotsToConfirm(r, gridBands) === 0) return false;
      if (modeFilter !== 'all' && refFilter !== 'notworked') {
        // A reference never worked has no mode, so "not worked" plus a mode is
        // a contradiction: the mode filter stands aside rather than emptying
        // the list.
        if (!(r.modes ?? []).includes(modeFilter)) return false;
        // Not-confirmed must mean not confirmed ON THIS MODE. An entity worked
        // on CW and confirmed on SSB is still a CW entity to chase, and the
        // whole point of the filter is to find those.
        if (refFilter === 'worked_notconf' && (r.confirmed_modes ?? []).includes(modeFilter)) return false;
      }
      if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
      return true;
    });
    const dir = refSortDir === 'asc' ? 1 : -1;
    return rows.sort((a, b) => {
      if (refSort === 'name') {
        // localeCompare, not a byte comparison: these lists are Spanish, French
        // and Italian place names, where "Ávila" belongs beside "Avilés" rather
        // than after "Zaragoza".
        const c = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
        if (c !== 0) return c * dir;
        return a.ref.localeCompare(b.ref) * dir; // stable tie-break
      }
      if (refSort === 'group') {
        // Prefix order (XE, YV, ZL…) — how a DX operator actually thinks about
        // entities. Blank prefixes sink to the bottom regardless of direction,
        // so an unresolved entity never heads the list.
        const ag = a.group ?? '', bg = b.group ?? '';
        if (!ag !== !bg) return ag ? -1 : 1;
        const c = ag.localeCompare(bg, undefined, { sensitivity: 'base' });
        if (c !== 0) return c * dir;
        return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
      }
      return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
    });
  }, [current, refSearch, refFilter, modeFilter, refSort, refSortDir, gridBands]);

  // The gap itself, over whatever the other filters left on screen: the number
  // of cells an operator would have to turn green to close it.
  const slotGap = useMemo(
    () => filteredRefs.reduce((n, r) => n + slotsToConfirm(r, gridBands), 0),
    [filteredRefs, gridBands],
  );

  // The group column earns its width only when the list actually carries one
  // (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
  // value IS the primary prefix, so it is labelled as such.
  const hasGroup = useMemo(() => filteredRefs.some((r) => !!r.group), [filteredRefs]);
  const groupColLabel = current && current.code.toUpperCase() === 'DXCC' ? t('awp.prefixCol') : t('awp.groupCol');

  return (
    <div className="flex h-full min-h-0">
      {/* Award list */}
      <div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
        <div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
          <AwardIcon className="size-4 text-primary" />
          <span className="text-sm font-semibold">{t('awp.awards')}</span>
          <div className="flex-1" />
          <Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title={t('awp.editAwards')}>
            <Pencil className="size-3.5" />
          </Button>
          <Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
            title={t('awp.rescanTitle')}>
            {loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
            {t('awp.rescan')}
          </Button>
        </div>
        {/* setRescanTick as well as setByCode. The grid was refreshed on save
            and the statistics matrix was not, so changing which confirmations
            count — LoTW only, LoTW plus cards — left the whole table showing
            the previous rule's numbers with nothing to say they were stale.
            That is how a setting comes to look as though it does nothing. */}
        <AwardEditor open={editing} onClose={() => setEditing(false)}
          onSaved={() => { setByCode({}); setRescanTick((t) => t + 1); loadList(); onAwardsChanged?.(); }} />
        {/* Quick selector — scales when there are many awards. */}
        {awardList.length > 0 && (
          <div className="px-2 py-2 border-b border-border/40">
            <Select value={selected} onValueChange={(code) => { setRefSearch(''); compute(code); }}>
              <SelectTrigger className="h-8 text-xs"><SelectValue placeholder={t('awp.selectAward')} /></SelectTrigger>
              <SelectContent className="max-h-80">
                {awardList.map((a) => (
                  <SelectItem key={a.code} value={a.code} className="text-xs">
                    <span className="font-semibold">{a.code}</span>
                    <span className="text-muted-foreground"> — {a.name}</span>
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
        )}
        <div className="flex-1 overflow-auto">
          {err && <div className="p-3 text-xs text-destructive">{err}</div>}
          {awardList.map((a) => {
            const r = byCode[`${a.code}|${modeFilter}`];
            return (
              <button
                key={a.code}
                onClick={() => { setRefSearch(''); compute(a.code); }}
                className={cn(
                  'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors',
                  selected === a.code && 'bg-accent/60',
                  a.valid === false && 'opacity-50',
                )}
              >
                <div className="flex items-baseline justify-between gap-2">
                  <span className="font-semibold text-sm">{a.code}</span>
                  {r ? (
                    <span className="text-[11px] font-mono text-muted-foreground">
                      <span className="text-success">{r.confirmed}</span>
                      /<span className="text-foreground">{r.worked}</span>
                      {r.total > 0 && <span className="text-muted-foreground/70"> {t('awp.of')} {r.total}</span>}
                    </span>
                  ) : (
                    <span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
                  )}
                </div>
                <div className="text-[11px] text-muted-foreground truncate mb-1">{a.name}</div>
                {r && <ProgressBar worked={r.worked} confirmed={r.confirmed} total={r.total} />}
              </button>
            );
          })}
        </div>
      </div>

      {/* Detail */}
      <div className="flex-1 flex flex-col min-h-0">
        {!current ? (
          <div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
            {loading ? t('awp.computing') : t('awp.noData')}
          </div>
        ) : (
          <>
            <div className="px-4 py-3 border-b border-border/60">
              <div className="flex items-baseline gap-2">
                <h3 className="text-lg font-bold">{current.code}</h3>
                <span className="text-sm text-muted-foreground">{current.name}</span>
              </div>
              <div className="mt-1 flex items-center gap-4 text-sm">
                <span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span>
                <span><span className="font-bold text-success">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
                <span><span className="font-bold text-info">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
                {current.total > 0 && (
                  <span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span>
                )}
              </div>
              <div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></div>
            </div>

            {/* Band breakdown */}
            {(current.bands ?? []).length > 0 && (
              <div className="px-4 py-2 border-b border-border/60">
                <div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">{t('awp.byBand')}</div>
                <div className="flex flex-wrap gap-1.5">
                  {(current.bands ?? []).map((b) => (
                    <div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
                      <span className="font-mono font-semibold">{b.band}</span>{' '}
                      <span className="text-success font-mono">{b.confirmed}</span>
                      <span className="text-muted-foreground font-mono">/{b.worked}</span>
                    </div>
                  ))}
                </div>
              </div>
            )}

            {/* References toolbar */}
            <div className="flex items-center gap-2 px-4 py-2 flex-wrap">
              <div className="relative">
                <Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
                <Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
              </div>
              <div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
                {([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')], ['slots_notconf', t('awp.filterSlotsNotCfmd')]] as const).map(([k, label]) => (
                  <button key={k} onClick={() => setRefFilter(k)}
                    className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
                    {label}
                  </button>
                ))}
              </div>
              <div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
                {([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
                  <button key={k} onClick={() => setModeFilter(k)}
                    className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
                    {label}
                  </button>
                ))}
              </div>
              <span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
              {slotGap > 0 && (
                <span className="text-xs text-muted-foreground" title={t('awp.slotGapTip')}>
                  · <span className="font-semibold text-foreground">{slotGap}</span> {t('awp.slotGap')}
                </span>
              )}
              {/* Only for an award scoped to a DXCC entity. "In this award's
                  scope but with no reference" needs a scope to be in: on a
                  worldwide reference award — POTA, SOTA, IOTA, WWFF — every
                  contact anywhere would qualify, so the answer is always none.
                  The button used to be there regardless and could only ever
                  open a window saying it had found nothing. */}
              {awardList.find((a) => a.code === selected)?.scoped && (
                <button
                  onClick={() => setShowMissing(true)}
                  className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
                  title={t('awp.missingRefsTitle')}
                >
                  <AlertTriangle className="size-3" /> {t('awp.missingRefs')}
                </button>
              )}
              <div className="flex-1" />
              {/* Legend */}
              <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
                <span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-muted-foreground" />W</span>
                <span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-warning" />C</span>
                <span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-success" />V</span>
              </div>
              <div className="flex items-center rounded-md border border-border overflow-hidden">
                <button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
                <button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.listView')} onClick={() => setView('list')}><List className="size-3.5" /></button>
                <button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.statistics')} onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
              </div>
            </div>

            {/* Statistics matrix: status × band, by mode category */}
            {view === 'stats' ? (
              <div className="flex-1 overflow-auto px-4 pb-3">
                {statsLoading || !stats ? (
                  <div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.computing')}</div>
                ) : (
                  <table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
                    <thead className="sticky top-0 z-10">
                      <tr className="bg-card">
                        <th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.statistic')}</th>
                        {statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
                        <th className="py-1.5 px-2 font-medium border-b border-border text-center" title={t('awp.totalHint')}>{t('awp.total')}</th>
                        <th className="py-1.5 px-2 font-medium border-b border-border text-center" title={t('awp.grandHint')}>{t('awp.grand')}</th>
                      </tr>
                    </thead>
                    <tbody>
                      {statsRows.map((row, i) => {
                        const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
                        return (
                          <tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
                            <td className={cn('sticky left-0 bg-card py-1 pr-3 border-b border-border/30 whitespace-nowrap',
                              isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
                            {statsBandIdx.map((i) => { const c = row.cells[i] ?? 0; return (
                              <td key={i} className={cn('text-center py-1 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-success/15 text-success' : 'text-muted-foreground/30')}>{c || ''}</td>
                            ); })}
                            <td className="text-center py-1 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
                            <td className="text-center py-1 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
                          </tr>
                        );
                      })}
                    </tbody>
                    {challenge !== null && (
                      <tfoot>
                        <tr>
                          <td className="sticky left-0 bg-card py-1.5 pr-3 font-semibold whitespace-nowrap" title={t('awp.challengeHint')}>
                            {t('awp.challenge')}
                          </td>
                          <td colSpan={statsBandIdx.length} className="py-1.5 text-[11px] text-muted-foreground">
                            {t('awp.challengeBands')}
                          </td>
                          <td className="text-center py-1.5 px-2 font-mono font-bold text-success" colSpan={2}>{challenge}</td>
                        </tr>
                      </tfoot>
                    )}
                  </table>
                )}
              </div>
            ) : view === 'grid' ? (
              <div className="flex-1 overflow-auto px-4 pb-3">
                <table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
                  <thead className="sticky top-0 z-10">
                    <tr className="bg-card">
                      <th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
                        <button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
                          {t('awp.ref')}
                          {refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
                        </button>
                      </th>
                      {hasGroup && (
                        <th className="text-left py-1.5 pr-3 font-medium border-b border-border w-20">
                          <button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
                            {groupColLabel}
                            {refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
                          </button>
                        </th>
                      )}
                      <th className="text-left py-1.5 pr-3 font-medium border-b border-border">
                        <button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
                          {t('awp.description')}
                          {refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
                        </button>
                      </th>
                      {gridBands.map((b) => (
                        <th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
                      ))}
                    </tr>
                  </thead>
                  <tbody>
                    {filteredRefs.map((r) => (
                      <tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
                        <td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
                        {hasGroup && (
                          <td className="py-1 pr-3 font-mono font-semibold border-b border-border/30">{r.group}</td>
                        )}
                        <td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">{r.name}</td>
                        {gridBands.map((b) => {
                          const s = cellStatus(r, b);
                          return (
                            <td key={b} className="border-b border-l border-border/30 p-0 text-center">
                              {s === 'none' ? <span className="block w-11 h-7" /> : (
                                <button
                                  className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110',
                                    // Chasing the slots still to confirm, the
                                    // confirmed ones are context, not the answer:
                                    // a row is kept for its W cells and its V
                                    // cells would otherwise be the loudest thing
                                    // on it. Faded rather than hidden — which
                                    // band is already done is worth seeing.
                                    refFilter === 'slots_notconf' && s !== 'worked' && 'opacity-25')}
                                  title={t('awp.cellTitle', { ref: r.ref, band: b })}
                                  onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
                                >{CELL_LABEL[s]}</button>
                              )}
                            </td>
                          );
                        })}
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            ) : (
              <div className="flex-1 overflow-auto px-4 pb-3">
                <table className="w-full text-sm">
                  <thead className="sticky top-0 bg-card">
                    <tr className="text-left text-muted-foreground border-b border-border">
                      <th className="py-1.5 pr-2 font-medium w-24">
                        <button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
                          {t('awp.ref')}
                          {refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
                        </button>
                      </th>
                      <th className="py-1.5 pr-2 font-medium">
                        <button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
                          {t('awp.name')}
                          {refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
                        </button>
                      </th>
                      <th className="py-1.5 pr-2 font-medium w-40">
                        <button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
                          {groupColLabel}
                          {refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
                        </button>
                      </th>
                      <th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
                      <th className="py-1.5 font-medium">{t('awp.bands')}</th>
                    </tr>
                  </thead>
                  <tbody>
                    {filteredRefs.map((r) => (
                      <tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
                        <td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
                        <td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
                        <td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
                        <td className="py-1 pr-2">
                          {!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
                            : r.validated ? <span className="text-success">{t('awp.validated')}</span>
                            : r.confirmed ? <span className="text-warning">{t('awp.confirmed')}</span>
                            : <span className="text-muted-foreground">{t('awp.worked')}</span>}
                        </td>
                        <td className="py-1 font-mono text-muted-foreground">
                          {r.bands.map((b) => (
                            <span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-success font-semibold' : r.confirmed_bands.includes(b) && 'text-warning font-semibold')}>{b}</span>
                          ))}
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            )}
          </>
        )}
      </div>

      {cell && current && (
        <CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} onPaperQSL={onPaperQSL} />
      )}
      {showMissing && current && (
        <MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
      )}
    </div>
  );
}

// MissingQSOModal lists contacts within an award's scope that carry NO
// reference — the silent gaps. Rows open the QSO editor so the operator can add
// the missing reference (e.g. a department for DDFM).
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';

// How many references the assign dropdown will put in the DOM at once. Above
// this a search box appears beside it and the rest are held back, counted.
const REF_MENU_MAX = 300;

function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
  const { t } = useI18n();
  const [qsos, setQsos] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);
  const [sel, setSel] = useState<Set<number>>(new Set());
  const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
  const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
  const [refSearch, setRefSearch] = useState('');
  const [assignRef, setAssignRef] = useState('');
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');

  const load = () => {
    setLoading(true);
    setSel(new Set());
    AwardMissingQSOs(code)
      .then((r) => setQsos((r ?? []) as any))
      .catch(() => setQsos([]))
      .finally(() => setLoading(false));
  };
  useEffect(() => { load(); }, [code]);
  // The award's reference list drives the "assign" dropdown (e.g. China
  // provinces) — fetched ONLY once there is something to assign it to.
  //
  // Missing-reference detection needs a DXCC scope, so a worldwide award like
  // POTA always has zero rows here and says so. It was still loading every
  // reference behind that message: on a log with the full POTA park list
  // imported, tens of thousands of them, feeding a dropdown that could not be
  // used for anything. That is the window freezing on a screen with nothing to
  // offer.
  useEffect(() => {
    if (loading || qsos.length === 0) { setRefs([]); return; }
    ListAwardReferences(code)
      .then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
      .catch(() => setRefs([]));
  }, [code, loading, qsos.length]);

  // What the assign dropdown actually renders. Bounded, and it says how many it
  // is holding back rather than silently showing the first few hundred.
  const shownRefs = useMemo(() => {
    const q = refSearch.trim().toUpperCase();
    const rows = q
      ? refs.filter((r) => r.code.includes(q) || r.name.toUpperCase().includes(q))
      : refs;
    return rows.slice(0, REF_MENU_MAX);
  }, [refs, refSearch]);
  const hiddenRefs = useMemo(() => {
    const q = refSearch.trim().toUpperCase();
    const total = q
      ? refs.filter((r) => r.code.includes(q) || r.name.toUpperCase().includes(q)).length
      : refs.length;
    return Math.max(0, total - shownRefs.length);
  }, [refs, refSearch, shownRefs.length]);

  const qthOf = (q: any) => String(q.qth || q.notes || '');
  const sorted = useMemo(() => {
    const dir = sortDir === 'asc' ? 1 : -1;
    const val = (q: any): string => {
      switch (sortKey) {
        case 'qso_date': return String(q.qso_date ?? '');
        case 'callsign': return String(q.callsign ?? '').toUpperCase();
        case 'band': return String(q.band ?? '');
        case 'mode': return String(q.mode ?? '');
        case 'country': return String(q.country ?? '').toUpperCase();
        case 'qth': return qthOf(q).toUpperCase();
      }
    };
    return [...qsos].sort((a, b) => val(a).localeCompare(val(b)) * dir);
  }, [qsos, sortKey, sortDir]);

  const ids = useMemo(() => sorted.map((q) => q.id as number).filter(Boolean), [sorted]);
  const allSelected = ids.length > 0 && ids.every((id) => sel.has(id));
  const toggleAll = () => setSel(allSelected ? new Set() : new Set(ids));
  const toggle = (id: number) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
  const setSort = (k: MissingSortKey) => {
    if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
    else { setSortKey(k); setSortDir('asc'); }
  };

  async function applyAssign() {
    if (!assignRef || sel.size === 0) return;
    setBusy(true); setMsg('');
    try {
      const assignedIds = Array.from(sel);
      const n = await AssignAwardRefToQSOs(code, assignRef, assignedIds);
      // Optimistic: the assigned contacts now carry a reference, so drop them
      // from the list locally instead of re-running the slow whole-log scan.
      const done = new Set(assignedIds);
      setQsos((list) => list.filter((q) => !done.has(q.id as number)));
      setSel(new Set());
      setMsg(t('awp.assignedMsg', { code, ref: assignRef, n }));
    } catch (e: any) {
      setMsg(String(e?.message ?? e));
    } finally { setBusy(false); }
  }

  const stations = useMemo(() => new Set(qsos.map((q) => String(q.callsign || '').toUpperCase())).size, [qsos]);
  const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };

  const SortTh = ({ k, label, className }: { k: MissingSortKey; label: string; className?: string }) => (
    <th className={cn('py-1 pr-2 font-medium cursor-pointer select-none hover:text-foreground', className)} onClick={() => setSort(k)}>
      <span className="inline-flex items-center gap-0.5">{label}
        {sortKey === k && (sortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
      </span>
    </th>
  );

  return (
    <div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
      <div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
        <div className="flex items-center gap-2 px-4 py-2.5 border-b">
          <AlertTriangle className="size-4 text-warning" />
          <span className="font-semibold text-sm">{code} — {t('awp.contactsMissingRef')}</span>
          {name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
          <div className="flex-1" />
          <button onClick={load} disabled={loading}
            className="flex items-center gap-1 text-[11px] border border-border rounded px-2 py-1 hover:bg-accent/50 disabled:opacity-50"
            title={t('awp.recomputeTitle')}>
            {loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} {t('awp.refresh')}
          </button>
          <button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
        </div>
        <div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
          {t('awp.missingScopeHelp')}{onEditQSO && ` ${t('awp.orClickRow')}`}
        </div>

        {/* Bulk-assign toolbar */}
        <div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
          <span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
          {/* A search box beside the dropdown, and a bounded list inside it.
              A reference list can hold tens of thousands of entries (POTA
              parks, Russian districts); every one of them as a menu item is
              hundreds of thousands of DOM nodes and a window that stops
              answering. Nobody scrolls to K-4521 anyway — they type it. */}
          {refs.length > REF_MENU_MAX && (
            <Input className="h-7 w-40 text-xs font-mono" value={refSearch}
              placeholder={t('awp.filterReferences')}
              onChange={(e) => setRefSearch(e.target.value)} />
          )}
          <Select value={assignRef} onValueChange={setAssignRef}>
            <SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
            <SelectContent className="max-h-72">
              {shownRefs.map((r) => (
                <SelectItem key={r.code} value={r.code}>
                  <span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
                </SelectItem>
              ))}
              {hiddenRefs > 0 && (
                <div className="px-2 py-1.5 text-[11px] text-muted-foreground">
                  {t('awp.refsNarrow', { n: hiddenRefs })}
                </div>
              )}
            </SelectContent>
          </Select>
          <Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
            {busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
          </Button>
          {msg && <span className="text-[11px] text-success">{msg}</span>}
        </div>

        <div className="flex-1 overflow-auto">
          {loading ? (
            <div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.scanning')}</div>
          ) : qsos.length === 0 ? (
            <div className="p-4 text-xs text-muted-foreground">
              {t('awp.noGaps')}
            </div>
          ) : (
            <table className="w-full text-xs">
              <thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
                <tr>
                  <th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
                  <SortTh k="qso_date" label={t('awp.dateUtc')} className="pl-0" />
                  <SortTh k="callsign" label={t('awp.callsign')} />
                  <SortTh k="band" label={t('awp.band')} />
                  <SortTh k="mode" label={t('awp.mode')} />
                  <SortTh k="country" label={t('awp.country')} />
                  <SortTh k="qth" label={t('awp.qthNote')} />
                </tr>
              </thead>
              <tbody>
                {sorted.map((q, i) => {
                  const id = q.id as number;
                  return (
                    <tr key={id ?? i}
                      className={cn('border-b border-border/30', sel.has(id) && 'bg-accent/30', onEditQSO && 'hover:bg-accent/40')}>
                      <td className="py-1 px-3" onClick={(e) => e.stopPropagation()}>
                        <Checkbox checked={sel.has(id)} onCheckedChange={() => toggle(id)} />
                      </td>
                      <td className={cn('py-1 font-mono', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{fmt(q.qso_date)}</td>
                      <td className={cn('py-1 pr-2 font-mono font-semibold', onEditQSO && 'cursor-pointer')} onClick={() => onEditQSO && id && onEditQSO(id)}>{q.callsign}</td>
                      <td className="py-1 pr-2">{q.band}</td>
                      <td className="py-1 pr-2">{q.mode}</td>
                      <td className="py-1 pr-2 text-muted-foreground truncate max-w-[140px]">{q.country}</td>
                      <td className="py-1 pr-3 text-muted-foreground truncate max-w-[260px]">{qthOf(q)}</td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          )}
        </div>
        <div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
          <span className="font-semibold text-foreground">{stations}</span> {t('awp.stations')} ·{' '}
          {qsos.length} {t('awp.contactsWithoutRef')}
        </div>
      </div>
    </div>
  );
}

// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
function CellQSOModal({ code, cell, modeClass, onClose, onPaperQSL }: {
  code: string; cell: { ref: string; band: string; name?: string }; modeClass: string;
  onClose: () => void;
  // Send this callsign to the QSL Manager's Paper QSL view. Chasing a
  // confirmation starts here — an unconfirmed entity, the contact behind it —
  // and used to continue by retyping the callsign into another tab.
  onPaperQSL?: (call: string) => void;
}) {
  const { t } = useI18n();
  const [qsos, setQsos] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    setLoading(true);
    AwardCellQSOs(code, cell.ref, cell.band, modeClass)
      .then((r) => setQsos((r ?? []) as any))
      .catch(() => setQsos([]))
      .finally(() => setLoading(false));
  }, [code, cell.ref, cell.band, modeClass]);

  const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };

  return (
    <div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
      <div className="bg-card border border-border rounded-lg shadow-xl w-[640px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
        <div className="flex items-center gap-2 px-4 py-2.5 border-b">
          <span className="font-semibold text-sm">{code} · <span className="font-mono">{cell.ref}</span> · {cell.band}</span>
          {cell.name && <span className="text-xs text-muted-foreground truncate">{cell.name}</span>}
          <div className="flex-1" />
          <button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
        </div>
        <div className="flex-1 overflow-auto">
          {loading ? (
            <div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.loading')}</div>
          ) : qsos.length === 0 ? (
            <div className="p-4 text-xs text-muted-foreground">{t('awp.noQsos')}</div>
          ) : (
            <table className="w-full text-xs">
              <thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
                <tr><th className="py-1 px-3 font-medium">{t('awp.dateUtc')}</th><th className="py-1 pr-2 font-medium">{t('awp.callsign')}</th><th className="py-1 pr-2 font-medium">{t('awp.band')}</th><th className="py-1 pr-2 font-medium">{t('awp.mode')}</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
              </thead>
              <tbody>
                {qsos.map((q, i) => (
                  <tr key={q.id ?? i} className="border-b border-border/30">
                    <td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
                    <td className="py-1 pr-2 font-mono font-semibold">
                      {onPaperQSL ? (
                        <button type="button" className="hover:underline text-primary"
                          title={t('awp.paperQslTip')}
                          onClick={() => { onPaperQSL(String(q.callsign ?? '')); onClose(); }}>
                          {q.callsign}
                        </button>
                      ) : q.callsign}
                    </td>
                    <td className="py-1 pr-2">{q.band}</td>
                    <td className="py-1 pr-2">{q.mode}</td>
                    <td className="py-1 pr-3 text-muted-foreground">{[isQSLConfirmed(q.lotw_rcvd) && 'LoTW', isQSLConfirmed(q.qsl_rcvd) && 'QSL', isQSLConfirmed(q.eqsl_rcvd) && 'eQSL'].filter(Boolean).join(', ')}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          )}
        </div>
        <div className="px-4 py-2 border-t text-[11px] text-muted-foreground">{qsos.length} QSO{qsos.length > 1 ? 's' : ''}</div>
      </div>
    </div>
  );
}
