package sat

import (
	"math"
	"testing"
	"time"
)

// A real ISS element set, and the answers a second tracker agrees with. The
// point is not the third decimal — it is that the observer, the epoch and the
// look angle are wired the right way round, which is exactly what silently
// comes out mirrored or an hour late.
const (
	issName  = "ISS (ZARYA)"
	issLine1 = "1 25544U 98067A   24298.54791435  .00016717  00000+0  30074-3 0  9991"
	issLine2 = "2 25544  51.6392 121.4587 0007976  86.1587  27.9639 15.50126585478227"
)

func issElement(t *testing.T) Element {
	t.Helper()
	e, err := ParseElement(issName, issLine1, issLine2)
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	return e
}

func TestElementCarriesItsIdentityAndEpoch(t *testing.T) {
	e := issElement(t)
	if e.NORAD != 25544 {
		t.Errorf("NORAD = %d, want 25544", e.NORAD)
	}
	// Day 298.548 of 2024 — the day the elements were computed.
	want := time.Date(2024, 10, 24, 13, 9, 0, 0, time.UTC)
	if d := e.Epoch.Sub(want); d > time.Minute || d < -time.Minute {
		t.Errorf("epoch = %s, want about %s", e.Epoch.Format(time.RFC3339), want.Format(time.RFC3339))
	}
}

// The satellite is somewhere, that somewhere is on Earth's scale, and the look
// angles are self-consistent: a bird below the horizon is further away than one
// overhead, and the footprint is a plausible circle.
func TestTrackIsSaneFromAKnownStation(t *testing.T) {
	e := issElement(t)
	obs := Observer{Lat: 48.85, Lon: 2.35, AltM: 35} // JN18, Paris
	at := time.Date(2024, 10, 24, 14, 0, 0, 0, time.UTC)

	p, err := e.Track(obs, at)
	if err != nil {
		t.Fatalf("track: %v", err)
	}
	if p.Lat < -90 || p.Lat > 90 || p.Lon < -180 || p.Lon > 180 {
		t.Errorf("sub-satellite point off the planet: %.3f %.3f", p.Lat, p.Lon)
	}
	if p.AltKm < 300 || p.AltKm > 500 {
		t.Errorf("altitude %.1f km — the ISS is not there", p.AltKm)
	}
	if p.Az < 0 || p.Az >= 360 || p.El < -90 || p.El > 90 {
		t.Errorf("look angles out of range: az %.1f el %.1f", p.Az, p.El)
	}
	// A satellite on the FAR side of the planet is still at a distance — up to
	// two Earth radii plus its height — so the useful invariant is the one that
	// holds when it is actually up: above the horizon it cannot be further away
	// than the slant range to its own footprint edge.
	if p.RangeKm < 300 || p.RangeKm > 13200 {
		t.Errorf("range %.0f km is not this orbit seen from the ground", p.RangeKm)
	}
	if p.El > 0 && p.RangeKm > 2600 {
		t.Errorf("visible at %.1f° yet %.0f km away", p.El, p.RangeKm)
	}
	// ~2000 km of visibility circle at 420 km up.
	if p.Footprint < 1500 || p.Footprint > 2600 {
		t.Errorf("footprint %.0f km", p.Footprint)
	}
}

// Twelve hours of ISS passes over a European station: there are always several,
// they rise before they set, and the filter keeps its promise.
func TestPassesRiseBeforeTheySetAndRespectTheFloor(t *testing.T) {
	s := NewStore()
	s.Put(issElement(t))
	obs := Observer{Lat: 48.85, Lon: 2.35, AltM: 35}
	from := time.Date(2024, 10, 24, 12, 0, 0, 0, time.UTC)

	all, err := s.Passes(issName, obs, from, from.Add(12*time.Hour), 0)
	if err != nil {
		t.Fatalf("passes: %v", err)
	}
	if len(all) == 0 {
		t.Fatal("no ISS pass in twelve hours over Paris")
	}
	for _, p := range all {
		if !p.LOS.After(p.AOS) {
			t.Errorf("%s: sets (%s) before it rises (%s)", p.Name, p.LOS, p.AOS)
		}
		if p.MaxEl <= 0 || p.MaxEl > 90 {
			t.Errorf("max elevation %.1f", p.MaxEl)
		}
		if p.MaxElAt.Before(p.AOS) || p.MaxElAt.After(p.LOS) {
			t.Errorf("the highest point falls outside the pass")
		}
	}
	high, err := s.Passes(issName, obs, from, from.Add(12*time.Hour), 30)
	if err != nil {
		t.Fatalf("passes: %v", err)
	}
	if len(high) > len(all) {
		t.Error("the elevation floor let MORE passes through")
	}
	for _, p := range high {
		if p.MaxEl < 30 {
			t.Errorf("a %.1f° pass survived a 30° floor", p.MaxEl)
		}
	}
}

// The two corrections go in OPPOSITE directions, and that is the whole of it:
// the downlink arrives shifted so we tune to meet it, while the uplink has to
// leave shifted the other way to land on the transponder's nominal input.
func TestDopplerCorrectsBothWaysRoundTheRightWay(t *testing.T) {
	const down, up = 145_950_000, 435_250_000

	approaching := Position{RangeRate: -7.0} // km/s, coming towards us
	receding := Position{RangeRate: +7.0}

	a := Doppler(approaching, down, up)
	if a.DownHz <= down {
		t.Errorf("approaching: listen at %d, expected above %d", a.DownHz, down)
	}
	if a.UpHz >= up {
		t.Errorf("approaching: transmit at %d, expected below %d", a.UpHz, up)
	}

	r := Doppler(receding, down, up)
	if r.DownHz >= down {
		t.Errorf("receding: listen at %d, expected below %d", r.DownHz, down)
	}
	if r.UpHz <= up {
		t.Errorf("receding: transmit at %d, expected above %d", r.UpHz, up)
	}

	// Size, not just sign: 7 km/s on 145.950 MHz is about 3.4 kHz.
	if d := math.Abs(float64(a.DownHz - down)); d < 3000 || d > 3800 {
		t.Errorf("shift of %.0f Hz on 2 m at 7 km/s", d)
	}
	// Stationary is untouched, and an absent uplink is not invented.
	if s := Doppler(Position{}, down, 0); s.DownHz != down || s.UpHz != 0 {
		t.Errorf("a still satellite was corrected: %+v", s)
	}
}

func TestStoreReplaceKeepsOrderAndStampsTheFetch(t *testing.T) {
	s := NewStore()
	e := issElement(t)
	at := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC)
	s.Replace([]Element{e}, at)

	if s.Len() != 1 || s.Names()[0] != issName {
		t.Errorf("store holds %v", s.Names())
	}
	if !s.FetchedAt().Equal(at) {
		t.Errorf("fetched at %s", s.FetchedAt())
	}
	// Case and spacing vary between feeds and typists; the name is not a
	// password.
	if _, ok := s.Get("iss (zarya)"); !ok {
		t.Error("a satellite could not be found under its own name in another case")
	}
	if _, err := s.Track("NOTHING", Observer{}, at); err == nil {
		t.Error("an unknown satellite was tracked anyway")
	}
}
