package pskrtgt

import (
	"testing"
	"time"
)

// feed builds a watcher with a window already populated, so the analysis can be
// pinned without a broker.
func feed(target string, spots ...spot) *Watcher {
	w := New(Config{MyCall: "F4BPO", MyGrid: "JN36BQ"})
	w.target, w.mode, w.dialHz = target, "FT8", 14_074_000
	w.spots = spots
	return w
}

func rep(tx, txGrid, rx, rxGrid string, snr int, offset int, ago time.Duration) spot {
	return spot{
		TxCall: tx, TxGrid: txGrid, RxCall: rx, RxGrid: rxGrid,
		SNR: snr, Freq: 14_074_000 + int64(offset), at: time.Now().Add(-ago),
	}
}

func TestHeardYouIsTheOperatorsOwnCallOnly(t *testing.T) {
	w := feed("YI5RLS",
		rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 20*time.Second),
		rep("F4XYZ", "JN36", "YI5RLS", "LM43", -8, 900, 30*time.Second),
	)
	a := w.Snapshot()
	if !a.HeMe {
		t.Fatal("the DX decoded the operator and the panel says he did not")
	}
	if a.HeMeSNR != -14 || a.HeMeOffset != 1200 {
		t.Errorf("he_me = %d dB @ %d Hz, want -14 dB @ 1200 Hz", a.HeMeSNR, a.HeMeOffset)
	}
	// The operator is not part of the pileup he is calling into: counting
	// yourself as competition is how a "1 caller" band looks contested.
	if a.PileupCount != 1 {
		t.Errorf("pileup = %d, want 1 (the other station only)", a.PileupCount)
	}
	// F4XYZ shares the operator's Maidenhead field, so the path from this
	// region is demonstrably open.
	if !a.PathOpen || a.FromMyAreaCount != 1 {
		t.Errorf("from my area = %d (open=%v), want 1 open", a.FromMyAreaCount, a.PathOpen)
	}
}

func TestNearHimNeedsHisSquareAndTheOperatorsCall(t *testing.T) {
	// He transmits (so his square is known), and a station in that square hears
	// the operator. He himself has decoded nobody.
	w := feed("YI5RLS",
		rep("YI5RLS", "LM43", "OH5CX", "KP30", -3, 0, 40*time.Second),
		rep("F4BPO", "JN36", "YI9XY", "LM43CC", -19, 1500, 25*time.Second),
	)
	a := w.Snapshot()
	if a.TargetGrid != "LM43" {
		t.Fatalf("his square = %q, want LM43", a.TargetGrid)
	}
	if a.NearHimCount != 1 || len(a.NearHimTop) != 1 || a.NearHimTop[0].Call != "YI9XY" {
		t.Errorf("near him = %d %v, want the one receiver in his square", a.NearHimCount, a.NearHimTop)
	}
	// He uploads nothing: the panel must be able to say so, or an operator
	// reads an empty panel as a closed band.
	if a.TargetUploads {
		t.Error("he received nothing in the window, yet the panel claims he uploads")
	}
	if a.HeMe {
		t.Error("nobody reported HIM decoding the operator")
	}
}

func TestWindowDropsWhatIsTooOld(t *testing.T) {
	// Inside the window: a report from six minutes ago is still evidence. Five
	// minutes was too short — measured against DXHunter on the same station at
	// the same moment, it hid a third of the decodes and a co-area station.
	w := feed("YI5RLS",
		rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 6*time.Minute),
	)
	if a := w.Snapshot(); !a.HeMe {
		t.Errorf("a six-minute-old report was dropped from a ten-minute window: %+v", a)
	}
	// Past it, it goes.
	w = feed("YI5RLS",
		rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 11*time.Minute),
	)
	if a := w.Snapshot(); a.HeMe || a.Spots != 0 {
		t.Errorf("an eleven-minute-old report survived: %+v", a)
	}
}

func TestSuggestOffsetAvoidsTheOccupiedBinsAndTheCeiling(t *testing.T) {
	// Busy from 1000 to 1500 Hz, empty from 1560 to 2400, ceiling 2400.
	bins := []Bin{}
	for hz := 1020; hz <= 1500; hz += binHz {
		bins = append(bins, Bin{OffsetHz: hz, Count: 3})
	}
	bins = append(bins, Bin{OffsetHz: 2400, Count: 1})
	got := suggestOffset(bins, 2400)
	if got < 1620 || got > 2340 {
		t.Errorf("suggested %d Hz, want somewhere in the empty 1560-2400 run", got)
	}
	// A passband with no gap at all still gets an answer — the quietest slot,
	// which is what an operator would look for by eye. What it must never do is
	// advise ABOVE the ceiling: transmitting past the DX's filter is the one
	// outcome worse than picking a busy slot.
	if got := suggestOffset(bins, 1500); got <= 1000 || got > 1500 {
		t.Errorf("suggested %d Hz with a full 1500 Hz passband, want a slot inside it", got)
	}
	if got := suggestOffset(nil, 0); got != 0 {
		t.Errorf("suggested %d Hz with no data at all, want none", got)
	}
}

func TestTopicsFollowTheScope(t *testing.T) {
	w := New(Config{MyCall: "F4BPO", Scope: ScopeTarget})
	w.target, w.mode, w.band = "YI5RLS", "FT8", "20m"
	got := w.topicsLocked()
	want := []string{
		"pskr/filter/v2/+/FT8/YI5RLS/#",
		"pskr/filter/v2/+/FT8/+/YI5RLS/#",
		"pskr/filter/v2/+/FT8/F4BPO/#",
	}
	if len(got) != len(want) {
		t.Fatalf("narrow scope = %v, want %v", got, want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Errorf("filter %d = %q, want %q", i, got[i], want[i])
		}
	}

	w.cfg.Scope = ScopeBand
	if got := w.topicsLocked(); len(got) != 1 || got[0] != "pskr/filter/v2/20m/FT8/#" {
		t.Errorf("band scope = %v, want the one band-wide filter", got)
	}
}

// The case reported from the air: a busy DX, 103 decodes across a 2805 Hz
// passband, and the panel drew the whole histogram with no advice under it.
func TestACrowdedPassbandStillGetsAnAnswer(t *testing.T) {
	// Every bin from 1020 to 2800 occupied — no clear run anywhere, and the
	// quietest slot is the answer.
	bins := []Bin{}
	for hz := 1020; hz <= 2760; hz += binHz {
		n := 5
		if hz == 2400 { // one slot noticeably quieter than the rest
			n = 1
		}
		bins = append(bins, Bin{OffsetHz: hz, Count: n})
	}
	got := suggestOffset(bins, 2805)
	if got == 0 {
		t.Fatal("no advice on a full passband — this is exactly when it is wanted")
	}
	if got < 2400 || got > 2460 {
		t.Errorf("suggested %d Hz, want the quietest slot around 2400", got)
	}
}

// Under the band-wide scope the window is the BAND's, not the target's: it is
// every FTx report on it, filtered by target only when the analysis is drawn.
// Clearing it on a target change threw away an hour of accumulated evidence at
// the exact moment it was worth something — the operator clicking a decode to
// ask "can he hear me" saw the report count drop to zero.
func TestBandScopeKeepsItsWindowAcrossATargetChange(t *testing.T) {
	for _, tc := range []struct {
		scope Scope
		keep  bool
	}{{ScopeBand, true}, {ScopeTarget, false}} {
		w := &Watcher{cfg: Config{Scope: tc.scope, Logf: func(string, ...any) {}}}
		w.target, w.mode = "OLD", "FT8"
		w.spots = []spot{{TxCall: "OLD", RxCall: "F4BPO", at: time.Now()}}

		w.dropWindowOfPreviousTarget()
		w.target = "NEW"

		if got := len(w.spots) > 0; got != tc.keep {
			t.Errorf("%s scope: window kept = %v, want %v", tc.scope, got, tc.keep)
		}
	}
}
