package moon

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

func TestFindStarOccultationPathsHR4799(t *testing.T) {
	location := time.FixedZone("CST", 8*3600)
	star := StarCoordinate{
		ID:                             "HR 4799",
		RA:                             189.1975,
		Dec:                            -5.831944444444,
		Epoch:                          time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
		Frame:                          CoordinateFrameJ2000,
		ProperMotionRACosDecMasPerYear: -28,
		ProperMotionDecMasPerYear:      -18,
	}
	paths, err := FindStarOccultationPaths(
		time.Date(2025, 6, 5, 0, 0, 0, 0, location),
		time.Date(2025, 6, 6, 0, 0, 0, 0, location),
		star, OccultationPathOptions{
			Step:            5 * time.Minute,
			TargetSpacingKM: 200,
		})
	if err != nil {
		t.Fatalf("FindStarOccultationPaths() error = %v", err)
	}
	if len(paths) != 1 {
		t.Fatalf("FindStarOccultationPaths() returned %d paths, want 1", len(paths))
	}
	path := paths[0]
	t.Logf("start=%s %.6f %.6f greatest=%s %.6f %.6f width=%.3f end=%s %.6f %.6f center=%d north=%d south=%d",
		path.Start.Time, path.Start.Longitude, path.Start.Latitude,
		path.Greatest.Time, path.Greatest.Longitude, path.Greatest.Latitude, path.Greatest.WidthKM,
		path.End.Time, path.End.Longitude, path.End.Latitude,
		len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit))
	if !path.Start.Time.Before(path.Greatest.Time) || !path.Greatest.Time.Before(path.End.Time) {
		t.Fatalf("path times are not ordered: start=%v greatest=%v end=%v", path.Start.Time, path.Greatest.Time, path.End.Time)
	}
	if !path.Complete {
		t.Fatal("global path must be marked complete")
	}
	if math.Abs(path.Start.Time.Sub(time.Date(2025, 6, 5, 17, 45, 26, 200000000, location)).Seconds()) > 5 {
		t.Fatalf("path start is too far from reference: got %v", path.Start.Time)
	}
	if math.Abs(path.End.Time.Sub(time.Date(2025, 6, 5, 22, 18, 53, 100000000, location)).Seconds()) > 5 {
		t.Fatalf("path end is too far from reference: got %v", path.End.Time)
	}
	if math.Abs(path.Start.Longitude-63.97388) > 0.5 || math.Abs(path.Start.Latitude-39.11772) > 0.5 {
		t.Fatalf("path start point is too far from reference: got %.6f %.6f", path.Start.Longitude, path.Start.Latitude)
	}
	if math.Abs(path.End.Longitude-172.33859) > 0.5 || math.Abs(path.End.Latitude-(-16.67159)) > 0.5 {
		t.Fatalf("path end point is too far from reference: got %.6f %.6f", path.End.Longitude, path.End.Latitude)
	}
	if math.Abs(path.Greatest.Longitude-121.55381) > 0.1 || math.Abs(path.Greatest.Latitude-6.79657) > 0.1 {
		t.Fatalf("greatest point is too far from reference: got %.6f %.6f", path.Greatest.Longitude, path.Greatest.Latitude)
	}
	if len(path.CenterLine) == 0 || len(path.NorthernLimit) == 0 || len(path.SouthernLimit) == 0 {
		t.Fatalf("path limits missing: center=%d north=%d south=%d", len(path.CenterLine), len(path.NorthernLimit), len(path.SouthernLimit))
	}
	if math.Abs(path.Greatest.Time.Sub(time.Date(2025, 6, 5, 20, 2, 7, 700000000, location)).Seconds()) > 5 {
		t.Fatalf("greatest time is too far from reference: got %v", path.Greatest.Time)
	}
	if math.Abs(path.Greatest.WidthKM-3571.9) > 20 {
		t.Fatalf("unexpected occultation width: got %.3f km", path.Greatest.WidthKM)
	}
	if len(path.NorthernLimit) != len(path.SouthernLimit) {
		t.Fatalf("limit counts differ: north=%d south=%d", len(path.NorthernLimit), len(path.SouthernLimit))
	}
	last := len(path.NorthernLimit) - 1
	if last < 1 || !path.NorthernLimit[0].Time.Equal(path.Start.Time) || !path.SouthernLimit[0].Time.Equal(path.Start.Time) ||
		!path.NorthernLimit[last].Time.Equal(path.End.Time) || !path.SouthernLimit[last].Time.Equal(path.End.Time) {
		t.Fatalf("limits do not cover the global start/end")
	}
	best, err := FindBestStarOccultations(
		time.Date(2025, 6, 5, 0, 0, 0, 0, location),
		time.Date(2025, 6, 6, 0, 0, 0, 0, location),
		star, OccultationSearchOptions{MaxEvents: 1},
	)
	if err != nil || len(best) != 1 {
		t.Fatalf("FindBestStarOccultations() = %d events, %v; want one event", len(best), err)
	}
	if math.Abs(best[0].Greatest.Sub(path.Greatest.Time).Seconds()) > 0.1 ||
		math.Abs(best[0].Observer.Longitude-path.Greatest.Longitude) > 1e-6 ||
		math.Abs(best[0].Observer.Latitude-path.Greatest.Latitude) > 1e-6 {
		t.Fatalf("best observer does not match global greatest: best=%v %+v path=%v %.9f %.9f",
			best[0].Greatest, best[0].Observer, path.Greatest.Time, path.Greatest.Longitude, path.Greatest.Latitude)
	}
}

func TestFindStarOccultationPathsHR4799LimitsRemainContinuous(t *testing.T) {
	location := time.FixedZone("CST", 8*3600)
	paths, err := FindStarOccultationPaths(
		time.Date(2025, 6, 5, 0, 0, 0, 0, location),
		time.Date(2025, 6, 6, 0, 0, 0, 0, location),
		hr4799CoordinateForTest(),
		OccultationPathOptions{Step: 30 * time.Second},
	)
	if err != nil {
		t.Fatalf("FindStarOccultationPaths() error = %v", err)
	}
	if len(paths) != 1 {
		t.Fatalf("FindStarOccultationPaths() returned %d paths, want 1", len(paths))
	}

	for _, limit := range []struct {
		name   string
		points []OccultationPathPoint
	}{
		{name: "northern", points: paths[0].NorthernLimit},
		{name: "southern", points: paths[0].SouthernLimit},
	} {
		for i := 1; i < len(limit.points); i++ {
			distance := occultationPathPointDistanceKM(limit.points[i-1], limit.points[i])
			if distance > 750 {
				t.Fatalf("%s limit jumps %.1f km between %v and %v", limit.name, distance,
					limit.points[i-1].Time, limit.points[i].Time)
			}
		}
		for i := 1; i+1 < len(limit.points); i++ {
			detour := occultationPathPointDistanceKM(limit.points[i-1], limit.points[i]) +
				occultationPathPointDistanceKM(limit.points[i], limit.points[i+1]) -
				occultationPathPointDistanceKM(limit.points[i-1], limit.points[i+1])
			if detour > 100 {
				t.Fatalf("%s limit has a %.1f km three-point detour at %v", limit.name, detour, limit.points[i].Time)
			}
		}
	}
}

func occultationPathPointDistanceKM(a, b OccultationPathPoint) float64 {
	lat1 := a.Latitude * math.Pi / 180
	lat2 := b.Latitude * math.Pi / 180
	dLat := lat2 - lat1
	dLon := (b.Longitude - a.Longitude) * math.Pi / 180
	dLon = math.Atan2(math.Sin(dLon), math.Cos(dLon))
	h := math.Sin(dLat/2)*math.Sin(dLat/2) +
		math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2)
	return 2 * 6378.1366 * math.Asin(math.Sqrt(math.Min(1, h)))
}

func TestFindStarOccultationPathsReturnsFullPathOutsideQueryWindow(t *testing.T) {
	location := time.FixedZone("CST", 8*3600)
	greatest := time.Date(2025, 6, 5, 20, 2, 7, 700000000, location)
	start := greatest.Add(-time.Minute)
	end := greatest.Add(time.Minute)
	paths, err := FindStarOccultationPaths(start, end, hr4799CoordinateForTest(), OccultationPathOptions{Step: 5 * time.Minute})
	if err != nil {
		t.Fatalf("FindStarOccultationPaths() error = %v", err)
	}
	if len(paths) != 1 {
		t.Fatalf("FindStarOccultationPaths() returned %d paths, want 1", len(paths))
	}
	path := paths[0]
	if !path.Complete || !path.Start.Time.Before(start) || !path.End.Time.After(end) {
		t.Fatalf("path was clipped to query window: complete=%v start=%v window=%v..%v end=%v", path.Complete, path.Start.Time, start, end, path.End.Time)
	}
}

func TestGlobalStarOccultationQueriesSelectByGeometricGreatest(t *testing.T) {
	star := hr4799CoordinateForTest()
	dayStart := time.Date(2025, 6, 5, 0, 0, 0, 0, time.UTC)
	paths, err := FindStarOccultationPaths(
		dayStart, dayStart.Add(24*time.Hour), star,
		OccultationPathOptions{Step: 5 * time.Minute},
	)
	if err != nil || len(paths) != 1 {
		t.Fatalf("full-day FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
	}

	start := paths[0].Greatest.Time.Add(-500 * time.Microsecond)
	end := start.Add(time.Millisecond)
	narrowPaths, err := FindStarOccultationPaths(start, end, star, OccultationPathOptions{Step: 5 * time.Minute})
	if err != nil || len(narrowPaths) != 1 {
		t.Fatalf("narrow FindStarOccultationPaths() paths=%d err=%v, want one", len(narrowPaths), err)
	}
	if !occultationTimeWithinNumericalWindowForTest(narrowPaths[0].Greatest.Time, start, end) {
		t.Fatalf("path greatest %v is outside query window %v..%v", narrowPaths[0].Greatest.Time, start, end)
	}

	best, err := FindBestStarOccultations(start, end, star, OccultationSearchOptions{})
	if err != nil || len(best) != 1 {
		t.Fatalf("narrow FindBestStarOccultations() events=%d err=%v, want one", len(best), err)
	}
	if !occultationTimeWithinNumericalWindowForTest(best[0].Greatest, start, end) {
		t.Fatalf("best greatest %v is outside query window %v..%v", best[0].Greatest, start, end)
	}
}

func occultationTimeWithinNumericalWindowForTest(value, start, end time.Time) bool {
	const tolerance = 10 * time.Millisecond
	return !value.Before(start.Add(-tolerance)) && !value.After(end.Add(tolerance))
}

func TestOccultationPathOptionsValidate(t *testing.T) {
	if err := (OccultationPathOptions{Step: -time.Second}).Validate(); err == nil {
		t.Fatal("negative path step should be rejected")
	}
	if err := (OccultationPathOptions{Step: 500 * time.Millisecond}).Validate(); err == nil {
		t.Fatal("subsecond positive path step should be rejected")
	}
	if err := (OccultationPathOptions{Step: time.Second}).Validate(); err != nil {
		t.Fatalf("one-second path step rejected: %v", err)
	}
	if err := (OccultationPathOptions{TargetSpacingKM: math.NaN()}).Validate(); err == nil {
		t.Fatal("non-finite target spacing should be rejected")
	}
	if err := (OccultationPathOptions{TargetSpacingKM: 0.5}).Validate(); err == nil {
		t.Fatal("sub-kilometer target spacing should be rejected")
	}
	if err := (OccultationPathOptions{TargetSpacingKM: 1}).Validate(); err != nil {
		t.Fatalf("one-kilometer target spacing rejected: %v", err)
	}
}

func BenchmarkFindStarOccultationPathTargetSpacing(b *testing.B) {
	location := time.FixedZone("CST", 8*3600)
	star := hr4799CoordinateForTest()
	for _, spacing := range []float64{1, 10, 200} {
		b.Run(fmt.Sprintf("%.0fkm", spacing), func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				paths, err := FindStarOccultationPaths(
					time.Date(2025, 6, 5, 0, 0, 0, 0, location),
					time.Date(2025, 6, 6, 0, 0, 0, 0, location),
					star, OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: spacing},
				)
				if err != nil || len(paths) != 1 {
					b.Fatalf("FindStarOccultationPaths() paths=%d err=%v", len(paths), err)
				}
			}
		})
	}
}
