package bandopen

import (
	"strings"
	"testing"
	"time"
)

func at(min int) time.Time {
	return time.Date(2026, time.June, 15, 12, min, 0, 0, time.UTC)
}

// feed pushes spots and returns the last Opening produced, if any.
func feed(d *Detector, lat float64, spots ...Spot) *Opening {
	var last *Opening
	for _, s := range spots {
		if o := d.Add(s, lat); o != nil {
			last = o
		}
	}
	return last
}

// The signature that must fire: several distinct stations, single-hop range,
// one bearing sector, within the window.
func TestDetectsSingleHopEs(t *testing.T) {
	d := New(DefaultConfig())
	o := feed(d, 46,
		Spot{Call: "I0ABC", Band: "6m", DistKm: 1100, Bearing: 140, At: at(0)},
		Spot{Call: "IK1DEF", Band: "6m", DistKm: 900, Bearing: 150, At: at(1)},
		Spot{Call: "9A2GHI", Band: "6m", DistKm: 1200, Bearing: 120, At: at(2)},
		Spot{Call: "S51JKL", Band: "6m", DistKm: 1000, Bearing: 130, At: at(3)},
	)
	if o == nil {
		t.Fatal("four stations at single-hop range in one sector must read as an opening")
	}
	if o.Band != "6m" || o.Calls != 4 {
		t.Errorf("got band=%s calls=%d, want 6m/4", o.Band, o.Calls)
	}
	if o.MedianKm < 900 || o.MedianKm > 1200 {
		t.Errorf("median %d km outside the fed range", o.MedianKm)
	}
	if !o.InSeason {
		t.Error("June in the northern hemisphere is Es season")
	}
}

// Spots all round the compass are a busy band, not an opening — the bearing
// test is what separates the two.
func TestScatteredBearingsAreNotAnOpening(t *testing.T) {
	d := New(DefaultConfig())
	if o := feed(d, 46,
		Spot{Call: "A", Band: "6m", DistKm: 1100, Bearing: 10, At: at(0)},
		Spot{Call: "B", Band: "6m", DistKm: 900, Bearing: 110, At: at(1)},
		Spot{Call: "C", Band: "6m", DistKm: 1200, Bearing: 210, At: at(2)},
		Spot{Call: "D", Band: "6m", DistKm: 1000, Bearing: 300, At: at(3)},
	); o != nil {
		t.Errorf("bearings spread round the compass must not fire (got %+v)", o)
	}
}

// One station spotted by six skimmers is six spots and one station.
func TestRepeatedSpotsOfOneStationDoNotFire(t *testing.T) {
	d := New(DefaultConfig())
	var spots []Spot
	for i := 0; i < 6; i++ {
		spots = append(spots, Spot{Call: "I0ABC", Band: "6m", DistKm: 1100, Bearing: 140, At: at(i)})
	}
	if o := feed(d, 46, spots...); o != nil {
		t.Error("one distinct callsign is not an opening however often it is spotted")
	}
}

// Out of the single-hop window there is nothing to conclude: under ~500 km a
// 6 m contact is ordinary tropo.
func TestTropoRangeIsIgnored(t *testing.T) {
	d := New(DefaultConfig())
	if o := feed(d, 46,
		Spot{Call: "A", Band: "6m", DistKm: 120, Bearing: 140, At: at(0)},
		Spot{Call: "B", Band: "6m", DistKm: 200, Bearing: 145, At: at(1)},
		Spot{Call: "C", Band: "6m", DistKm: 90, Bearing: 150, At: at(2)},
		Spot{Call: "D", Band: "6m", DistKm: 150, Bearing: 135, At: at(3)},
	); o != nil {
		t.Error("short-range spots must not read as sporadic E")
	}
}

// Spread over more than the window is not one burst.
func TestSpotsOutsideTheWindowDoNotAccumulate(t *testing.T) {
	d := New(DefaultConfig())
	if o := feed(d, 46,
		Spot{Call: "A", Band: "6m", DistKm: 1100, Bearing: 140, At: at(0)},
		Spot{Call: "B", Band: "6m", DistKm: 900, Bearing: 150, At: at(20)},
		Spot{Call: "C", Band: "6m", DistKm: 1200, Bearing: 120, At: at(40)},
		Spot{Call: "D", Band: "6m", DistKm: 1000, Bearing: 130, At: at(60)},
	); o != nil {
		t.Error("spots an hour apart are not one opening")
	}
}

// HF is never announced: an "opening" on 20 m is the band's normal state.
func TestHFIsNotWatched(t *testing.T) {
	if Watched("20m") || Watched("40m") {
		t.Error("HF must not be watched")
	}
	if !Watched("6m") || !Watched("2m") || !Watched("4m") {
		t.Error("6/4/2 m must be watched")
	}
}

// Announced once, then quiet — an opening lasts hours and produces hundreds of
// spots; one alert is information, forty is noise.
func TestOneAnnouncementPerOpening(t *testing.T) {
	d := New(DefaultConfig())
	base := []Spot{
		{Call: "A", Band: "6m", DistKm: 1100, Bearing: 140, At: at(0)},
		{Call: "B", Band: "6m", DistKm: 900, Bearing: 150, At: at(1)},
		{Call: "C", Band: "6m", DistKm: 1200, Bearing: 120, At: at(2)},
		{Call: "D", Band: "6m", DistKm: 1000, Bearing: 130, At: at(3)},
	}
	if o := feed(d, 46, base...); o == nil {
		t.Fatal("expected the first opening")
	}
	if o := d.Add(Spot{Call: "E", Band: "6m", DistKm: 1050, Bearing: 135, At: at(4)}, 46); o != nil {
		t.Error("a second alert inside the quiet period is noise")
	}
}

// Out-of-season openings still fire — they are the ones worth knowing about —
// and are simply labelled as unusual.
func TestOutOfSeasonStillFiresButIsLabelled(t *testing.T) {
	d := New(DefaultConfig())
	nov := func(m int) time.Time { return time.Date(2026, time.November, 3, 9, m, 0, 0, time.UTC) }
	o := feed(d, 46,
		Spot{Call: "A", Band: "6m", DistKm: 1100, Bearing: 140, At: nov(0)},
		Spot{Call: "B", Band: "6m", DistKm: 900, Bearing: 150, At: nov(1)},
		Spot{Call: "C", Band: "6m", DistKm: 1200, Bearing: 120, At: nov(2)},
		Spot{Call: "D", Band: "6m", DistKm: 1000, Bearing: 130, At: nov(3)},
	)
	if o == nil {
		t.Fatal("an out-of-season opening must still be announced")
	}
	if o.InSeason {
		t.Error("November in the north is not Es season — it must be flagged unusual")
	}
}

// Both hemispheres have a summer peak and a lesser winter one, and both read as
// expected. What must come out as UNUSUAL is an equinox month.
func TestSeasonFollowsTheHemisphere(t *testing.T) {
	on := func(m time.Month) time.Time { return time.Date(2026, m, 15, 0, 0, 0, 0, time.UTC) }
	const north, south = 46.0, -33.0

	if !InSeason("6m", on(time.June), north) {
		t.Error("June is the northern main season")
	}
	if !InSeason("6m", on(time.December), north) {
		t.Error("December is the northern winter peak — not a surprise")
	}
	if !InSeason("6m", on(time.December), south) {
		t.Error("December is the southern main season")
	}
	if !InSeason("6m", on(time.June), south) {
		t.Error("June is the southern winter peak")
	}
	// The equinoxes are the quiet months in both hemispheres.
	for _, lat := range []float64{north, south} {
		for _, m := range []time.Month{time.March, time.April, time.September, time.October} {
			if InSeason("6m", on(m), lat) {
				t.Errorf("%v at lat %.0f should read as unusual", m, lat)
			}
		}
	}
}

// The sector must cope with the wrap at north: 350° and 10° are 20° apart.
func TestBearingArcWrapsAtNorth(t *testing.T) {
	lo, hi, spread := arc([]int{350, 10, 0, 355})
	if spread > 30 {
		t.Errorf("spread %d° across north should be small (lo=%d hi=%d)", spread, lo, hi)
	}
	if _, _, s := arc([]int{0, 90, 180, 270}); s < 270 {
		t.Errorf("bearings on all four quadrants should span nearly the circle, got %d", s)
	}
}

// Multi-hop must be detected, not thrown away.
//
// The detector used to cap paths at 2400 km, on the reasoning that past one hop
// the bearing test stops meaning anything. Nexus flagged a 6 m opening at
// 5477 km that OpsLog never saw: the spots were discarded before any test ran.
// Multi-hop Es is directional — a second hop leaves the sector the first one
// entered — so distance is not what tells an opening from noise. The sector is.
func TestMultiHopOpeningIsDetected(t *testing.T) {
	d := New(DefaultConfig())
	base := time.Date(2026, 6, 15, 18, 0, 0, 0, time.UTC)
	var got *Opening
	for i, call := range []string{"LU1AA", "PY2BB", "LU3CC", "PY4DD"} {
		if op := d.Add(Spot{
			Call: call, Band: "6m",
			DistKm:  5400 + i*40, // double hop, far past the old ceiling
			Bearing: +i * 5,      // one sector, as a real cloud illuminates
			At:      base.Add(time.Duration(i) * time.Minute),
		}, 47.0); op != nil {
			got = op
		}
	}
	if got == nil {
		t.Fatal("a four-station 5400 km burst in one sector was not reported as an opening")
	}
	if got.Band != "6m" {
		t.Errorf("band = %q, want 6m", got.Band)
	}
}

// The floor stays: a short path says nothing about the ionosphere.
func TestGroundWaveIsStillIgnored(t *testing.T) {
	d := New(DefaultConfig())
	base := time.Date(2026, 6, 15, 18, 0, 0, 0, time.UTC)
	for i, call := range []string{"F1AA", "F2BB", "F3CC", "F4DD"} {
		if op := d.Add(Spot{
			Call: call, Band: "6m", DistKm: 120, Bearing: 90,
			At: base.Add(time.Duration(i) * time.Minute),
		}, 47.0); op != nil {
			t.Fatalf("a 120 km burst was reported as an opening: %+v", op)
		}
	}
}

// A sector crossing north must not be labelled by its opposite.
//
// Sector() averaged the two bearings arithmetically, so 353–61° — plainly north
// — came out as (353+61)/2 = 207°, announced as SW. The worst possible error:
// not vague, but exactly reversed, to an operator who may turn a beam on it.
func TestSectorAcrossNorth(t *testing.T) {
	cases := []struct {
		min, max int
		want     string
	}{
		{353, 61, "NE"}, // the one seen in the field
		{303, 11, "NW"}, // the other one
		{35, 75, "NE"},  // no wrap, unchanged
		{170, 190, "S"}, // due south, no wrap
		{340, 20, "N"},  // straddling north evenly
		{260, 300, "W"}, // due west
	}
	for _, c := range cases {
		o := Opening{BearingMin: c.min, BearingMax: c.max}
		got := o.Sector()
		if !strings.HasPrefix(got, c.want+" ") {
			t.Errorf("Sector(%d–%d°) = %q, want it to start with %q", c.min, c.max, got, c.want)
		}
	}
}

// A band open in one direction must be found THROUGH the everyday noise.
//
// The detector used to demand that every station fit inside one 90° arc. On 10
// and 12 m with F2 open, reports arrive from all round the compass, the arc is
// 360°, and the test could never pass: the busier the band, the less likely an
// opening was announced. Reported from the field — 70 000 decodes, 10 and 12 m
// plainly open, nothing said.
func TestOpeningFoundThroughAllRoundNoise(t *testing.T) {
	d := New(DefaultConfig())
	base := time.Date(2026, 8, 11, 14, 0, 0, 0, time.UTC)
	// Four stations concentrated to the south-west — the opening.
	south := []struct {
		call string
		deg  int
	}{{"PY2AA", 220}, {"LU3BB", 228}, {"PY5CC", 235}, {"CX4DD", 240}}
	// And the usual Europeans scattered everywhere, which is what used to veto it.
	noise := []struct {
		call string
		deg  int
	}{{"DL1XX", 40}, {"SM2YY", 20}, {"EA3ZZ", 190}, {"UA9QQ", 70}, {"G4WW", 320}}

	var got *Opening
	for i, s := range noise {
		d.Add(Spot{Call: s.call, Band: "10m", DistKm: 1200, Bearing: s.deg,
			At: base.Add(time.Duration(i) * time.Second)}, 47.0)
	}
	for i, s := range south {
		if op := d.Add(Spot{Call: s.call, Band: "10m", DistKm: 9800, Bearing: s.deg,
			At: base.Add(time.Duration(10+i) * time.Second)}, 47.0); op != nil {
			got = op
		}
	}
	if got == nil {
		t.Fatal("an opening concentrated to the SW was not found among stations all round the compass")
	}
	mid := midBearing(got.BearingMin, got.BearingMax)
	if mid < 200 || mid > 260 {
		t.Errorf("sector middle %.0f° — the reported sector is not the busy one (%d–%d°)",
			mid, got.BearingMin, got.BearingMax)
	}
}

// Stations evenly all round the compass and nothing concentrated: still nothing.
// The point of the change was to stop requiring global agreement, not to start
// calling every busy band an opening.
func TestScatteredBandIsNotAnOpening(t *testing.T) {
	d := New(DefaultConfig())
	base := time.Date(2026, 8, 11, 14, 0, 0, 0, time.UTC)
	for i, deg := range []int{0, 60, 120, 180, 240, 300} {
		if op := d.Add(Spot{Call: string(rune('A'+i)) + "1AAA", Band: "10m",
			DistKm: 3000, Bearing: deg, At: base.Add(time.Duration(i) * time.Second)}, 47.0); op != nil {
			t.Fatalf("evenly scattered stations were called an opening: %+v", op)
		}
	}
}
