// Watchlist — the DXHunter concept as an OpsLog tab.
//
// One card per watched callsign (or prefix), the live spots that match it
// underneath, and the two questions an operator actually asks answered on every
// line: is this slot still NEEDED, and what would it be worth (the same NEW
// DXCC / band / mode / slot badges the cluster shows, resolved from the same
// index). A CONTEST entry is judged against the current UTC day — at midnight
// everything reads "work today" again; the boundary lives in the backend query,
// so there is nothing to reset.
//
// The design is DXHunter's — the layout, the badges, the wording — repainted in
// the app's theme tokens rather than its hard-coded slate/pink.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search, Check, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import {
  WatchlistEntries, WatchlistAdd, WatchlistRemove, WatchlistSetNotify,
  WatchlistSetContest, WatchlistWorkedSlots,
  GetWatchlistContestPattern, SetWatchlistContestPattern, OpenExternalURL,
} from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
import { useWatchlistSpots, matchesEntry, newBadge, type WLEntry } from '@/lib/watchlistSpots';

interface Props {
  spots: ClusterSpot[];
  spotStatus: Record<string, SpotStatusEntry>;
  onSpotSelect?: (s: ClusterSpot) => void;
  onSpotClick?: (s: ClusterSpot) => void;
}

export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) {
  const { t } = useI18n();
  const [entries, setEntries] = useState<WLEntry[]>([]);
  const [addCall, setAddCall] = useState('');
  const [addContest, setAddContest] = useState(false);
  const [search, setSearch] = useState('');
  // The filters survive leaving the tab: the component unmounts on every tab
  // switch, and filters that reset each time are filters nobody trusts.
  const [neededOnly, setNeededOnlyRaw] = useState(() => localStorage.getItem('opslog.wlNeededOnly') === '1');
  const [activeOnly, setActiveOnlyRaw] = useState(() => localStorage.getItem('opslog.wlActiveOnly') === '1');
  const [family, setFamilyRaw] = useState<'all' | 'normal' | 'contest'>(() => {
    const v = localStorage.getItem('opslog.wlFamily');
    return v === 'normal' || v === 'contest' ? v : 'all';
  });
  const setNeededOnly = (f: (v: boolean) => boolean) => setNeededOnlyRaw((v) => { const nv = f(v); try { localStorage.setItem('opslog.wlNeededOnly', nv ? '1' : '0'); } catch {} return nv; });
  const setActiveOnly = (f: (v: boolean) => boolean) => setActiveOnlyRaw((v) => { const nv = f(v); try { localStorage.setItem('opslog.wlActiveOnly', nv ? '1' : '0'); } catch {} return nv; });
  const setFamily = (v: 'all' | 'normal' | 'contest') => { setFamilyRaw(v); try { localStorage.setItem('opslog.wlFamily', v); } catch {} };
  // Mode filter — DXHunter's "All Modes" select. DIGI matches the digital class
  // (FT8, FT4, a generic DATA spot…), the named modes match exactly.
  const [modeFilter, setModeFilterRaw] = useState(() => localStorage.getItem('opslog.wlMode') || 'ALL');
  const setModeFilter = (v: string) => { setModeFilterRaw(v); try { localStorage.setItem('opslog.wlMode', v); } catch {} };
  const modeMatches = (s2: ClusterSpot): boolean => {
    if (modeFilter === 'ALL') return true;
    const m = (inferSpotMode(s2.comment ?? '', s2.freq_hz) || '').toUpperCase();
    if (modeFilter === 'DIGI') return !['', 'CW', 'SSB', 'USB', 'LSB', 'FM', 'AM'].includes(m);
    if (modeFilter === 'SSB') return m === 'SSB' || m === 'USB' || m === 'LSB';
    return m === modeFilter;
  };
  const [error, setError] = useState('');
  // A quiet confirmation under the toolbar; both messages clear themselves —
  // a stale "added" from ten minutes ago reads as a fresh one.
  const [notice, setNotice] = useState('');
  const noticeTimer = useRef<number | undefined>(undefined);
  const flash = (msg: string, isError: boolean) => {
    if (noticeTimer.current) window.clearTimeout(noticeTimer.current);
    setError(isError ? msg : '');
    setNotice(isError ? '' : msg);
    noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number;
  };


  const refresh = useCallback(async () => {
    try { setEntries(((await WatchlistEntries()) ?? []) as any as WLEntry[]); }
    catch (e: any) { setError(String(e?.message ?? e)); }
  }, []);
  // Refreshed on mount, when the backend auto-adds (event), and on a slow tick
  // so last-seen and the spot counters stay honest while the tab sits open.
  useEffect(() => {
    void refresh();
    const off = EventsOn('watchlist:changed', () => { void refresh(); });
    const id = window.setInterval(() => { void refresh(); }, 30_000);
    return () => { off(); window.clearInterval(id); };
  }, [refresh]);
  // The auto-contest pattern (DXHunter's contest_prefix): spots whose call
  // contains it are added as contest entries by the backend.
  const [pattern, setPattern] = useState('');
  useEffect(() => { GetWatchlistContestPattern().then((p: string) => setPattern(p ?? '')).catch(() => {}); }, []);

  // Live spots per entry — prefix-matched, newest first, deduped per band+mode
  // (one line per slot; the freshest spot represents it).
  // Which entries are on the air, and which of their slots are still needed.
  // One definition, shared with the docked watch-list widget — the answer
  // involves a debounced query per visible slot, and two copies of it would be
  // two bursts of the same question and two ideas of what "needed" means.
  const { spotsFor, workedFor, settled, onAir, worked } = useWatchlistSpots(entries, spots);

  const add = async () => {
    const c = addCall.trim().toUpperCase();
    if (!c) return;
    try {
      await WatchlistAdd(c, addContest);
      setAddCall('');
      // The confirmation is the app-level notice (App.tsx, on watchlist:changed)
      // — saying it twice, once per place a call can be added from, was noise.
      await refresh();
    } catch (e: any) { flash(String(e?.message ?? e), true); }
  };

  // One click, like the bell and the trophy beside it: removing a watchlist
  // entry is cheap to undo (type it again), so it does not earn a confirmation
  // the other two buttons do not have.
  const remove = async (call: string) => {
    try { await WatchlistRemove(call); await refresh(); }
    catch (e: any) { flash(String(e?.message ?? e), true); }
  };

  const isOnAir = onAir;

  const shown = entries.filter((e) => {
    if (family === 'normal' && e.isContest) return false;
    if (family === 'contest' && !e.isContest) return false;
    if (search && !e.callsign.includes(search.trim().toUpperCase())) return false;
    const list = (spotsFor.get(e.callsign) ?? []).filter(modeMatches).filter((s2) => settled(e, s2));
    if (activeOnly && list.length === 0) return false;
    if (neededOnly && !list.some((s2) => !workedFor(e, s2))) return false;
    return true;
  });

  const counters = useMemo(() => {
    let active = 0, needed = 0;
    for (const e of entries) {
      const list = (spotsFor.get(e.callsign) ?? []).filter(modeMatches).filter((s2) => settled(e, s2));
      if (list.length > 0) active++;
      if (list.some((s2) => !workedFor(e, s2))) needed++;
    }
    return { total: entries.length, active, needed };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [entries, spotsFor, worked, modeFilter]);

  // The cluster's own badge for a spot, read from the shared status index.
  const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => {
    const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)];
    const b = newBadge(st?.status);
    return b ? { label: t(b.key), color: b.colour } : null;
  };

  // Three decimals, and no trailing zeros beyond them: 7.056 rather than
  // 7.0560, 14.0745 rather than 14.074500. DXHunter's own rule, and the one an
  // operator reads a cluster line with.
  const fmtMHz = (hz: number) => {
    const [int, dec] = (hz / 1e6).toFixed(6).split('.');
    return int + '.' + dec.slice(0, 3) + dec.slice(3).replace(/0+$/, '');
  };

  const chip = (color: string, text: string, extra?: string) => (
    <span className={cn('px-1.5 py-0.5 rounded text-[11px] font-semibold border', extra)}
      style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, background: `color-mix(in srgb, ${color} 12%, transparent)` }}>
      {text}
    </span>
  );

  return (
    // Capped and centred: a card is a reading surface, and callsign-to-badge
    // lines stretched across a 34-inch window are not readable, they are long.
    <div className="flex flex-col min-h-0 flex-1 gap-2 p-2 w-full max-w-5xl mx-auto">
      {/* Header: the counters alone, centred — they are the tab's headline.
          Everything one INTERACTS with lives on the second row. */}
      <div className="flex items-center justify-center gap-2.5 text-xs text-muted-foreground">
        <Eye className="size-4 text-primary shrink-0" />
        <span className="flex items-center gap-1.5">{t('wl.cTotal')}
          <b className="px-2 py-0.5 rounded bg-muted text-foreground">{counters.total}</b></span>
        <span className="opacity-40">|</span>
        <span className="flex items-center gap-1.5">{t('wl.cActive')}
          <b className="px-2 py-0.5 rounded text-info border border-info/30 bg-info/10">{counters.active}</b></span>
        <span className="opacity-40">|</span>
        <span className="flex items-center gap-1.5">{t('wl.cNeeded')}
          <b className={cn('px-2 py-0.5 rounded border', counters.needed > 0
            ? 'text-warning border-warning/40 bg-warning/10'
            : 'text-muted-foreground border-border bg-muted/40')}>{counters.needed}</b></span>
      </div>
      {/* toolbar */}
      <div className="flex items-start justify-between gap-x-3 gap-y-1.5 flex-wrap">
        <div className="flex items-center gap-2 shrink-0">
        <Input className="h-8 w-40 font-mono placeholder:font-sans placeholder:normal-case" placeholder={t('wl.addPh')} value={addCall}
          onChange={(e) => setAddCall(e.target.value.toUpperCase())}
          onKeyDown={(e) => { if (e.key === 'Enter') void add(); }} />
        <label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('wl.contestHint')}>
          <Checkbox checked={addContest} onCheckedChange={(c) => setAddContest(!!c)} />
          <Trophy className="size-3.5 text-warning" /> {t('wl.addAsContest')}
        </label>
        <Button size="sm" className="h-8" onClick={() => void add()} disabled={!addCall.trim()}>
          <Plus className="size-3.5" /> {t('wl.add')}
        </Button>
        <Input className="h-8 w-28 font-mono placeholder:font-sans placeholder:normal-case" placeholder={t('wl.patternPh')}
          title={t('wl.patternHint')}
          value={pattern}
          onChange={(e) => setPattern(e.target.value.toUpperCase())}
          onBlur={() => { void SetWatchlistContestPattern(pattern.trim()); }} />
        </div>
        <div className="flex items-center gap-2 shrink-0">
        <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-28 pl-7 text-sm" placeholder={t('wl.searchPh')} value={search}
            onChange={(e) => setSearch(e.target.value)} />
        </div>
        {/* families kept together, told apart — per review of the DXHunter port,
            no global contest mode: each entry carries its own rule. */}
        <div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
          {([['all', t('wl.famAll')], ['normal', t('wl.famNormal')], ['contest', t('wl.famContest')]] as const).map(([k, label]) => (
            <button key={k} type="button" onClick={() => setFamily(k)}
              className={cn('px-2 py-1 border-l border-border first:border-l-0',
                family === k ? 'bg-accent font-medium' : 'text-muted-foreground hover:bg-accent/50')}>
              {label}
            </button>
          ))}
        </div>
        <select value={modeFilter} onChange={(e) => setModeFilter(e.target.value)}
          title={t('wl.modeFilter')}
          className="h-7 px-1.5 rounded-md border border-border bg-background text-xs text-foreground">
          {['ALL', 'CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'DIGI'].map((m) => (
            <option key={m} value={m}>{m === 'ALL' ? t('wl.allModes') : m}</option>
          ))}
        </select>
        <button type="button" onClick={() => setActiveOnly((v) => !v)}
          className={cn('px-2 py-1 rounded-md border text-xs', activeOnly ? 'bg-accent border-border font-medium' : 'border-border text-muted-foreground hover:bg-accent/50')}>
          {t('wl.activeOnly')}
        </button>
        <button type="button" onClick={() => setNeededOnly((v) => !v)}
          className={cn('px-2 py-1 rounded-md border text-xs', neededOnly ? 'bg-accent border-border font-medium' : 'border-border text-muted-foreground hover:bg-accent/50')}>
          {t('wl.neededOnly')}
        </button>
        </div>
      </div>
      {error && <div className="text-xs text-danger px-1">{error}</div>}
      {notice && <div className="text-xs text-success px-1">{notice}</div>}

      {/* cards */}
      <div className="flex-1 min-h-0 overflow-y-auto space-y-2 pr-1">
        {shown.length === 0 ? (
          <div className="text-sm text-muted-foreground text-center pt-16 max-w-md mx-auto leading-relaxed">
            {entries.length === 0 ? t('wl.empty') : t('wl.noneMatch')}
          </div>
        ) : shown.map((e) => {
          const all = (spotsFor.get(e.callsign) ?? []).filter(modeMatches).filter((s2) => settled(e, s2));
          const needed = all.filter((s) => !workedFor(e, s)).length;
          // Needed-only hides the worked LINES as well as the all-worked cards:
          // a filter that says needed and still lists five green Worked rows is
          // answering a different question than the one asked.
          const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
          return (
            <div key={e.callsign}
              className={cn('rounded border bg-card/70 p-3 transition-colors hover:bg-accent/20',
                needed > 0 ? 'border-warning/40' : 'border-border/70',
                e.isContest && 'border-l-4 border-l-warning')}>
              <div className="flex items-center gap-2 flex-wrap">
                {/* Proportional, not monospaced: DXHunter sets this one in the
                    interface font and the difference is the first thing an
                    operator notices with the two windows side by side. There is
                    nothing to align here — it is a heading, not a column. */}
                <span className="text-lg font-bold" style={{ color: '#f472b6' }}>{e.callsign}</span>
                {isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
                {e.isContest && (
                  <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border"
                    title={t('wl.contestHint')}>
                    <Trophy className="size-3" /> {t('wl.contest')}
                  </span>
                )}
                {e.isExpedition && chip('var(--chart-5)', '⚡ ' + t('wl.expedition'))}
                {e.clubLogTotalQSOs > 0 && <span className="text-[11px] text-muted-foreground">{e.clubLogTotalQSOs.toLocaleString()} QSOs{e.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}</span>}
                {e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
                {e.clubLogLiveStream && (
                  <a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }}
                    className="px-1.5 py-0.5 rounded text-[11px] font-semibold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
                )}
                {list.length > 0 && (needed > 0
                  ? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed }))
                  : chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))}
                {e.lastSeenStr && e.lastSeenStr !== 'Never' && (
                  <span className="text-[11px] text-muted-foreground">• {e.lastSeenStr}</span>
                )}
                {e.spotCount > 0 && (
                  <span className="text-[11px] text-muted-foreground/70">• {t('wl.totalSpots', { n: e.spotCount })}</span>
                )}
                <div className="flex-1" />
                <button type="button" title={t('wl.toggleContest')}
                  onClick={() => void WatchlistSetContest(e.callsign, !e.isContest).then(refresh)}
                  className={cn('p-1 rounded hover:bg-muted', e.isContest ? 'text-warning' : 'text-muted-foreground/40')}>
                  <Trophy className="size-3.5" />
                </button>
                <button type="button" title={e.notify ? t('wl.notifyOff') : t('wl.notifyOn')}
                  onClick={() => void WatchlistSetNotify(e.callsign, !e.notify).then(refresh)}
                  className={cn('p-1 rounded hover:bg-muted', e.notify ? 'text-warning' : 'text-muted-foreground/40')}>
                  {e.notify ? <Bell className="size-3.5" /> : <BellOff className="size-3.5" />}
                </button>
                <button type="button" title={t('wl.remove')}
                  onClick={() => void remove(e.callsign)}
                  className="p-1 rounded hover:bg-muted text-muted-foreground/40 hover:text-danger">
                  <Trash2 className="size-3.5" />
                </button>
              </div>

              {list.length > 0 ? (
                <div className="mt-2 space-y-1 max-h-52 overflow-y-auto">
                  {list.slice(0, 12).map((s, i) => {
                    const done = workedFor(e, s);
                    const badge = dxccBadge(s);
                    const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
                    return (
                      <button key={i} type="button"
                        onClick={() => onSpotSelect?.(s)}
                        onDoubleClick={() => onSpotClick?.(s)}
                        title={t('wl.spotTip')}
                        className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
                          !done && 'border-l-2 border-warning')}>
                        {/* Worked or wanted, said with a symbol at the head of
                            the line as well as with the stripe down its side —
                            the same two marks DXHunter uses, and the one an eye
                            finds first when a card holds ten rows. */}
                        {done
                          ? <Check className="size-4 shrink-0 text-success" />
                          : <AlertTriangle className="size-4 shrink-0 text-warning" />}
                        {/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
                        <span className="font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
                        <span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</span>
                        <span className="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
                        <span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
                        <span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{fmtMHz(s.freq_hz)}</span>
                        {badge && chip(badge.color, badge.label)}
                        <div className="flex-1" />
                        {done
                          ? chip('var(--success)', e.isContest ? t('wl.todayOk') : t('wl.worked'))
                          : chip('var(--warning)', e.isContest ? t('wl.workToday') : t('wl.needed'))}
                        <span className="text-muted-foreground/70 shrink-0">{s.time_utc ?? ''}</span>
                      </button>
                    );
                  })}
                </div>
              ) : (
                <div className="mt-2 text-[11px] text-muted-foreground text-center py-1.5 bg-muted/30 rounded">
                  {t('wl.noSpots')}
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}
