// What the watch list is HEARING, and what of it is still needed.
//
// Two places ask the same question — the Watchlist tab and the docked widget —
// and the answer involves a debounced round trip to the logbook per visible
// slot. Written twice it would be two definitions of "needed" drifting apart,
// and two bursts of the same query on every spot; written here it is one.
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ClusterSpot } from '@/components/ClusterGrid';
import { inferSpotMode } from '@/lib/spot';
import { WatchlistWorkedSlots } from '../../wailsjs/go/main/App';

export interface WLEntry {
  callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
  isContest: boolean; notify: boolean;
  isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
  clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
}

// A spot is ON AIR while its last sighting is this fresh.
export const ON_AIR_MS = 10 * 60 * 1000;

// Exact unless the entry carries a trailing * — the same rule the backend's
// Match applies to the live stream, mirrored so the list and the alerts can
// never disagree about what an entry covers.
export function matchesEntry(call: string, pattern: string): boolean {
  const c = call.toUpperCase();
  if (pattern.endsWith('*')) {
    const p = pattern.slice(0, -1);
    return p !== '' && c.startsWith(p);
  }
  return c === pattern;
}

// What the cluster's verdict is worth saying, and in what colour.
//
// The order of severity is the one the band map and Chase new use: a new entity
// first, then the band and mode inside it. Shared because the tab and the docked
// widget must not label the same spot differently — an operator reads one of
// them to decide whether to leave what they are doing.
export const NEW_BADGES: Record<string, { key: string; colour: string }> = {
  'new': { key: 'wl.newDxcc', colour: 'var(--danger)' },
  'new-band-mode': { key: 'clg2.newBandMode', colour: 'var(--danger)' },
  'new-band': { key: 'clg2.newBand', colour: 'var(--warning)' },
  'new-mode': { key: 'clg2.newMode', colour: 'var(--caution)' },
  'new-slot': { key: 'clg2.newSlot', colour: '#5AC8FA' },
};

export function newBadge(status?: string): { key: string; colour: string } | null {
  return (status && NEW_BADGES[status]) || null;
}

export interface WatchlistSpots {
  // The raw verdicts, exposed for dependency arrays: it changes only when an
  // answer arrives, where the closures below are new on every render.
  worked: Record<string, boolean>;
  // The spots each entry covers, deduplicated by band+mode.
  spotsFor: Map<string, ClusterSpot[]>;
  // Worked on this exact slot (and, for a contest entry, today).
  workedFor: (e: WLEntry, s: ClusterSpot) => boolean;
  // Whether the logbook has actually answered for this pair yet. A spot whose
  // verdict has not come back is NOT drawn: showing it as needed and
  // withdrawing it half a second later made the list twitch on every burst,
  // and nobody needs a spot 400 ms early — they need it settled.
  settled: (e: WLEntry, s: ClusterSpot) => boolean;
  onAir: (e: WLEntry) => boolean;
}

export function useWatchlistSpots(entries: WLEntry[], spots: ClusterSpot[]): WatchlistSpots {
  const [worked, setWorked] = useState<Record<string, boolean>>({});

  const spotsFor = useMemo(() => {
    const map = new Map<string, ClusterSpot[]>();
    for (const e of entries) map.set(e.callsign, []);
    for (const s of spots) {
      for (const e of entries) {
        if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
      }
    }
    for (const [k, list] of map) {
      const seen = new Set<string>();
      map.set(k, list.filter((s) => {
        const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`;
        if (seen.has(key)) return false;
        seen.add(key);
        return true;
      }));
    }
    return map;
  }, [spots, entries]);

  // Debounced: a spot burst must cost one round trip, not one per spot.
  const queryTimer = useRef<number | undefined>(undefined);
  useEffect(() => {
    if (queryTimer.current) window.clearTimeout(queryTimer.current);
    queryTimer.current = window.setTimeout(async () => {
      const queries: { call: string; band: string; mode: string; contest: boolean }[] = [];
      const keys: string[] = [];
      for (const e of entries) {
        for (const s of spotsFor.get(e.callsign) ?? []) {
          const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
          queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest });
          keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`);
        }
      }
      if (queries.length === 0) { setWorked({}); return; }
      try {
        const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? [];
        // MERGED, not replaced: replacing made every already-answered key
        // momentarily unknown on each refresh, which re-hid settled lines.
        setWorked((prev) => {
          const next = { ...prev };
          keys.forEach((k, i) => { next[k] = !!res[i]; });
          return next;
        });
      } catch { /* the badges just stay conservative */ }
    }, 150) as unknown as number;
    return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
  }, [spotsFor, entries]);

  const wkey = (e: WLEntry, s: ClusterSpot) =>
    `${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`;

  return {
    worked,
    spotsFor,
    workedFor: (e, s) => worked[wkey(e, s)] ?? false,
    settled: (e, s) => wkey(e, s) in worked,
    onAir: (e) => (spotsFor.get(e.callsign) ?? []).some((s) => {
      const ts = Date.parse(String((s as any).received_at ?? ''));
      return ts > 0 && Date.now() - ts < ON_AIR_MS;
    }),
  };
}
