package eclipse

import (
	"testing"
	"time"

	"b612.me/astro/basic"
)

func TestLocalSolarEclipseOnDateByLocalDay(t *testing.T) {
	loc := time.FixedZone("CDT", -5*3600)
	lon, lat, height := -87.65, 41.85, 0.0

	testCases := []struct {
		name string
		date time.Time
		want bool
	}{
		{
			name: "day before no eclipse",
			date: time.Date(2024, 4, 7, 12, 0, 0, 0, loc),
			want: false,
		},
		{
			name: "local event day overlaps",
			date: time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
			want: true,
		},
		{
			name: "day after no eclipse",
			date: time.Date(2024, 4, 9, 12, 0, 0, 0, loc),
			want: false,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			info, ok := LocalSolarEclipseOnDate(tc.date, lon, lat, height)
			if ok != tc.want {
				t.Fatalf("LocalSolarEclipseOnDate(%v) got %v want %v", tc.date, ok, tc.want)
			}
			if !ok {
				return
			}
			if info.Type != SolarEclipsePartial {
				t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipsePartial)
			}
			if info.GreatestEclipse.Location() != loc {
				t.Fatalf("greatest eclipse location mismatch: got %q want %q", info.GreatestEclipse.Location(), loc)
			}
			if info.PartialStart.Day() != 8 || info.PartialEnd.Day() != 8 {
				t.Fatalf("unexpected local date span: begin=%v end=%v", info.PartialStart, info.PartialEnd)
			}
		})
	}
}

func TestLocalSolarEclipseVisibilityFilter(t *testing.T) {
	loc := time.FixedZone("JST", 9*3600)
	date := time.Date(2024, 4, 9, 12, 0, 0, 0, loc)
	lon, lat, height := 139.6917, 35.6895, 0.0

	geometricInfo, geometricOK := GeometricLocalSolarEclipseOnDate(date, lon, lat, height)
	if !geometricOK {
		t.Fatalf("expected geometric local eclipse on date")
	}
	if geometricInfo.Type != SolarEclipsePartial {
		t.Fatalf("unexpected geometric eclipse type: got %s want %s", geometricInfo.Type, SolarEclipsePartial)
	}
	if geometricInfo.VisibleAtGreatest {
		t.Fatalf("expected geometric eclipse to be below horizon at greatest: %+v", geometricInfo)
	}

	visibleInfo, visibleOK := LocalSolarEclipseOnDate(date, lon, lat, height)
	if visibleOK {
		t.Fatalf("expected visible filter to reject invisible eclipse, got %+v", visibleInfo)
	}
}

func TestLocalSolarEclipseSearchSemantics(t *testing.T) {
	loc := time.UTC
	lon, lat, height := -0.1278, 51.5074, 0.0
	current := ClosestLocalSolarEclipseNASABulletinSplitK(time.Date(2025, 3, 29, 12, 0, 0, 0, loc), lon, lat, height)
	if current.Type != SolarEclipsePartial {
		t.Fatalf("unexpected current eclipse type: got %s want %s", current.Type, SolarEclipsePartial)
	}

	assertSameLocalSolarEclipse(
		t,
		"ClosestLocalSolarEclipse(default)",
		ClosestLocalSolarEclipse(current.GreatestEclipse, lon, lat, height),
		current,
		time.Second,
	)

	last := LastLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse, lon, lat, height)
	assertSameLocalSolarEclipse(t, "LastLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse)", last, current, time.Second)

	closest := ClosestLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse, lon, lat, height)
	assertSameLocalSolarEclipse(t, "ClosestLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse)", closest, current, time.Second)

	next := NextLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse, lon, lat, height)
	if !next.GreatestEclipse.After(current.GreatestEclipse) {
		t.Fatalf("NextLocalSolarEclipseNASABulletinSplitK should be strictly future: current=%v next=%v", current.GreatestEclipse, next.GreatestEclipse)
	}
	if next.Type != SolarEclipsePartial {
		t.Fatalf("unexpected next eclipse type: got %s want %s", next.Type, SolarEclipsePartial)
	}
	assertSolarTimeClose(t, "NextLocalSolarEclipseNASABulletinSplitK", next.GreatestEclipse, time.Date(2026, 8, 12, 18, 13, 21, 0, time.UTC), 2*time.Minute)
}

func TestLocalSolarEclipseSearchSkipsInvisibleCurrentCandidate(t *testing.T) {
	loc := time.FixedZone("JST", 9*3600)
	lon, lat, height := 139.6917, 35.6895, 0.0
	current, ok := GeometricLocalSolarEclipseOnDate(time.Date(2024, 4, 9, 12, 0, 0, 0, loc), lon, lat, height)
	if !ok {
		t.Fatalf("expected geometric local eclipse on date")
	}
	if current.VisibleAtGreatest {
		t.Fatalf("expected current geometric eclipse to be below horizon: %+v", current)
	}

	next := NextLocalSolarEclipseNASABulletinSplitK(current.GreatestEclipse, lon, lat, height)
	if next.Type == SolarEclipseNone || next.GreatestEclipse.IsZero() {
		t.Fatalf("expected search to skip the invisible current candidate and find a future visible eclipse")
	}
	if !next.GreatestEclipse.After(current.GreatestEclipse) {
		t.Fatalf("expected strictly future local solar eclipse: current=%v next=%v", current.GreatestEclipse, next.GreatestEclipse)
	}
}

func TestLocalTotalSolarEclipseSearch(t *testing.T) {
	loc := time.UTC
	lon, lat, height := -104.1, 25.3, 0.0
	date := time.Date(2024, 4, 7, 0, 0, 0, 0, loc)

	next, ok := NextLocalTotalSolarEclipse(date, lon, lat, height)
	if !ok {
		t.Fatal("expected to find next local total solar eclipse")
	}
	if next.Type != SolarEclipseTotal || !next.HasTotal {
		t.Fatalf("unexpected next total eclipse: %+v", next)
	}
	assertSolarTimeClose(t, "NextLocalTotalSolarEclipse", next.GreatestEclipse, time.Date(2024, 4, 8, 18, 17, 15, 0, loc), time.Minute)
	assertSolarDurationClose(t, "NextLocalTotalSolarEclipse duration", next.CentralEnd.Sub(next.CentralStart), 4*time.Minute+28*time.Second, 5*time.Second)

	last, ok := LastLocalTotalSolarEclipse(next.GreatestEclipse, lon, lat, height)
	if !ok {
		t.Fatal("expected to find previous local total solar eclipse")
	}
	if last.Type != SolarEclipseTotal || !last.HasTotal {
		t.Fatalf("unexpected last total eclipse: %+v", last)
	}
	assertSolarTimeClose(t, "LastLocalTotalSolarEclipse", last.GreatestEclipse, next.GreatestEclipse, time.Second)
	assertSolarDurationClose(t, "LastLocalTotalSolarEclipse duration", last.CentralEnd.Sub(last.CentralStart), 4*time.Minute+28*time.Second, 5*time.Second)
}

func TestLocalTotalSolarEclipseClosest(t *testing.T) {
	loc := time.UTC
	lon, lat, height := -104.1, 25.3, 0.0
	date := time.Date(2024, 4, 8, 12, 0, 0, 0, loc)

	info, ok := ClosestLocalTotalSolarEclipse(date, lon, lat, height)
	if !ok {
		t.Fatal("expected to find closest local total solar eclipse")
	}
	if info.Type != SolarEclipseTotal || !info.HasTotal {
		t.Fatalf("unexpected closest total eclipse: %+v", info)
	}
	assertSolarTimeClose(t, "ClosestLocalTotalSolarEclipse", info.GreatestEclipse, time.Date(2024, 4, 8, 18, 17, 15, 0, loc), time.Minute)
}

func TestLocalAnnularSolarEclipseSearch(t *testing.T) {
	loc := time.UTC
	lon, lat, height := -114.5, -22.0, 0.0
	date := time.Date(2024, 10, 1, 0, 0, 0, 0, loc)

	next, ok := NextLocalAnnularSolarEclipse(date, lon, lat, height)
	if !ok {
		t.Fatal("expected to find next local annular solar eclipse")
	}
	if next.Type != SolarEclipseAnnular || !next.HasAnnular || next.HasTotal {
		t.Fatalf("unexpected next annular eclipse: %+v", next)
	}
	assertSolarTimeClose(t, "NextLocalAnnularSolarEclipse", next.GreatestEclipse, time.Date(2024, 10, 2, 18, 44, 59, 0, loc), time.Minute)
	assertSolarDurationClose(t, "NextLocalAnnularSolarEclipse duration", next.CentralEnd.Sub(next.CentralStart), 7*time.Minute+25*time.Second, 5*time.Second)

	last, ok := LastLocalAnnularSolarEclipse(next.GreatestEclipse, lon, lat, height)
	if !ok {
		t.Fatal("expected to find previous local annular solar eclipse")
	}
	if last.Type != SolarEclipseAnnular || !last.HasAnnular || last.HasTotal {
		t.Fatalf("unexpected last annular eclipse: %+v", last)
	}
	assertSolarTimeClose(t, "LastLocalAnnularSolarEclipse", last.GreatestEclipse, next.GreatestEclipse, time.Second)
}

func TestLocalAnnularSolarEclipseClosest(t *testing.T) {
	loc := time.UTC
	lon, lat, height := -114.5, -22.0, 0.0
	date := time.Date(2024, 10, 2, 12, 0, 0, 0, loc)

	info, ok := ClosestLocalAnnularSolarEclipse(date, lon, lat, height)
	if !ok {
		t.Fatal("expected to find closest local annular solar eclipse")
	}
	if info.Type != SolarEclipseAnnular || !info.HasAnnular || info.HasTotal {
		t.Fatalf("unexpected closest annular eclipse: %+v", info)
	}
	assertSolarTimeClose(t, "ClosestLocalAnnularSolarEclipse", info.GreatestEclipse, time.Date(2024, 10, 2, 18, 44, 59, 0, loc), time.Minute)
}

func TestLocalCentralSolarEclipseVisibleRequiresCentralPhaseVisibility(t *testing.T) {
	info := LocalSolarEclipseInfo{
		Type:              SolarEclipseTotal,
		Longitude:         0,
		Latitude:          0,
		PartialStart:      time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC),
		PartialEnd:        time.Date(2024, 1, 1, 15, 0, 0, 0, time.UTC),
		CentralStart:      time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
		CentralEnd:        time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC),
		HasPartial:        true,
		HasCentral:        true,
		HasTotal:          true,
		VisibleAtGreatest: false,
	}
	if !localSolarEclipseVisible(info) {
		t.Fatalf("expected partial phase to be visible")
	}
	if localCentralSolarEclipseVisible(info) {
		t.Fatalf("expected central phase below horizon to be rejected")
	}

	info.Type = SolarEclipseAnnular
	info.HasTotal = false
	info.HasAnnular = true
	if localCentralSolarEclipseVisible(info) {
		t.Fatalf("expected annular central phase below horizon to be rejected")
	}
}

func TestLocalSolarEclipseInfoKeepsLocation(t *testing.T) {
	loc := time.FixedZone("UTC+08", 8*3600)
	lon, lat, height := -104.1, 25.3, 1234.0
	testCases := []struct {
		name string
		calc func(time.Time, float64, float64, float64) LocalSolarEclipseInfo
	}{
		{name: "nasa", calc: ClosestLocalSolarEclipseNASABulletinSplitK},
		{name: "iau", calc: ClosestLocalSolarEclipseIAUSingleK},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			info := tc.calc(time.Date(2024, 4, 8, 12, 0, 0, 0, loc), lon, lat, height)
			if info.Type != SolarEclipseTotal {
				t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipseTotal)
			}
			if info.Longitude != lon || info.Latitude != lat || info.Height != height {
				t.Fatalf("observer metadata mismatch: got (%f,%f,%f)", info.Longitude, info.Latitude, info.Height)
			}

			for _, item := range []struct {
				name string
				tm   time.Time
			}{
				{name: "GreatestEclipse", tm: info.GreatestEclipse},
				{name: "PartialStart", tm: info.PartialStart},
				{name: "PartialEnd", tm: info.PartialEnd},
				{name: "CentralStart", tm: info.CentralStart},
				{name: "CentralEnd", tm: info.CentralEnd},
			} {
				if item.tm.Location() != loc {
					t.Fatalf("%s location mismatch: got %q want %q", item.name, item.tm.Location(), loc)
				}
			}
			for _, point := range info.ContactPoints {
				if point.Time.Location() != loc {
					t.Fatalf("contact %s location mismatch: got %q want %q", point.Label, point.Time.Location(), loc)
				}
			}
		})
	}
}

func TestLocalSolarEclipseContactPoints(t *testing.T) {
	loc := time.FixedZone("UTC+08", 8*3600)
	info := ClosestLocalSolarEclipse(
		time.Date(2024, 4, 8, 12, 0, 0, 0, loc),
		-96.7970,
		32.7767,
		0,
	)
	if info.Type != SolarEclipseTotal {
		t.Fatalf("unexpected eclipse type: got %s want %s", info.Type, SolarEclipseTotal)
	}
	if got, want := len(info.ContactPoints), 4; got != want {
		t.Fatalf("contact point count = %d, want %d", got, want)
	}

	points := make(map[string]LocalSolarEclipseContactPoint, len(info.ContactPoints))
	for _, point := range info.ContactPoints {
		points[point.Label] = point
	}
	assertSolarFloatClose(t, "C1.ContactPositionAngle", points["C1"].ContactPositionAngle, 226.219228, 1e-3)
	assertSolarFloatClose(t, "C2.ContactPositionAngle", points["C2"].ContactPositionAngle, 19.137089, 1e-3)
	assertSolarFloatClose(t, "C2.MoonCenterPositionAngle", points["C2"].MoonCenterPositionAngle, 199.137089, 1e-3)
	assertSolarFloatClose(t, "C4.ContactClockwiseAngle", points["C4"].ContactClockwiseAngle, 310.781438, 1e-3)
}

func TestLocalSolarEclipseContactPointsFromMergedFrameLabels(t *testing.T) {
	frames := []basic.LocalSolarEclipseDiagramFrame{
		{
			JDE:           2460409.25,
			SunRadius:     1,
			MoonRadius:    1.05,
			PositionAngle: 199.137089,
			Label:         "Greatest",
			Labels:        []string{"C2", "Greatest", "C3"},
		},
	}
	points := localSolarEclipseContactPointsFromFrames(frames, time.UTC)
	if got, want := len(points), 2; got != want {
		t.Fatalf("contact point count = %d, want %d", got, want)
	}
	if points[0].Label != "C2" || points[1].Label != "C3" {
		t.Fatalf("labels = %#v, want [C2 C3]", []string{points[0].Label, points[1].Label})
	}
	assertSolarFloatClose(t, "C2.ContactPositionAngle", points[0].ContactPositionAngle, 19.137089, 1e-6)
	assertSolarFloatClose(t, "C3.ContactPositionAngle", points[1].ContactPositionAngle, 19.137089, 1e-6)
}

func TestLocalSolarEclipseIAUSingleKRemainsAvailable(t *testing.T) {
	date := time.Date(2024, 4, 8, 12, 0, 0, 0, time.UTC)
	lon, lat, height := -104.1, 25.3, 0.0
	defaultInfo := ClosestLocalSolarEclipse(date, lon, lat, height)
	iauInfo := ClosestLocalSolarEclipseIAUSingleK(date, lon, lat, height)

	if defaultInfo.Type != SolarEclipseTotal || iauInfo.Type != SolarEclipseTotal {
		t.Fatalf("unexpected eclipse types: default=%s iau=%s", defaultInfo.Type, iauInfo.Type)
	}
	assertSolarTimeClose(t, "GreatestEclipse", iauInfo.GreatestEclipse, defaultInfo.GreatestEclipse, time.Second)

	if !(iauInfo.CentralEnd.Sub(iauInfo.CentralStart) > defaultInfo.CentralEnd.Sub(defaultInfo.CentralStart)) {
		t.Fatalf("expected IAU central duration > NASA duration: iau=%v nasa=%v", iauInfo.CentralEnd.Sub(iauInfo.CentralStart), defaultInfo.CentralEnd.Sub(defaultInfo.CentralStart))
	}
	if !(iauInfo.Magnitude > defaultInfo.Magnitude) {
		t.Fatalf("expected IAU magnitude > NASA magnitude: iau=%.9f nasa=%.9f", iauInfo.Magnitude, defaultInfo.Magnitude)
	}
}

func TestLocalSolarEclipseAgainstNASABaseline(t *testing.T) {
	chicagoLoc := time.FixedZone("CDT", -5*3600)

	testCases := []struct {
		name                string
		date                time.Time
		lon                 float64
		lat                 float64
		height              float64
		wantType            SolarEclipseType
		wantGreatest        time.Time
		wantPartialStart    time.Time
		wantPartialEnd      time.Time
		wantMagnitude       float64
		wantObscuration     float64
		wantSunAltitude     float64
		wantSunAzimuth      float64
		wantCentralDuration time.Duration
	}{
		{
			name:             "2024-04-08 chicago partial",
			date:             time.Date(2024, 4, 8, 12, 0, 0, 0, chicagoLoc),
			lon:              -87.65,
			lat:              41.85,
			height:           0,
			wantType:         SolarEclipsePartial,
			wantGreatest:     time.Date(2024, 4, 8, 14, 7, 0, 0, chicagoLoc),
			wantPartialStart: time.Date(2024, 4, 8, 12, 51, 0, 0, chicagoLoc),
			wantPartialEnd:   time.Date(2024, 4, 8, 15, 22, 0, 0, chicagoLoc),
			wantMagnitude:    0.942,
			wantObscuration:  0.938,
			wantSunAltitude:  52,
			wantSunAzimuth:   211,
		},
		{
			name:                "2024-04-08 greatest total",
			date:                time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
			lon:                 -104.1,
			lat:                 25.3,
			height:              0,
			wantType:            SolarEclipseTotal,
			wantGreatest:        time.Date(2024, 4, 8, 18, 17, 15, 0, time.UTC),
			wantPartialStart:    time.Date(2024, 4, 8, 16, 58, 0, 0, time.UTC),
			wantPartialEnd:      time.Date(2024, 4, 8, 19, 40, 0, 0, time.UTC),
			wantMagnitude:       1.0566,
			wantObscuration:     1.0,
			wantSunAltitude:     70,
			wantSunAzimuth:      150,
			wantCentralDuration: 4*time.Minute + 28*time.Second,
		},
		{
			name:                "2024-10-02 greatest annular",
			date:                time.Date(2024, 10, 2, 0, 0, 0, 0, time.UTC),
			lon:                 -114.5,
			lat:                 -22.0,
			height:              0,
			wantType:            SolarEclipseAnnular,
			wantGreatest:        time.Date(2024, 10, 2, 18, 44, 59, 0, time.UTC),
			wantPartialStart:    time.Date(2024, 10, 2, 17, 3, 0, 0, time.UTC),
			wantPartialEnd:      time.Date(2024, 10, 2, 20, 33, 0, 0, time.UTC),
			wantMagnitude:       0.9326,
			wantObscuration:     0.871,
			wantSunAltitude:     69,
			wantSunAzimuth:      31,
			wantCentralDuration: 7*time.Minute + 25*time.Second,
		},
	}

	const (
		partialTimeTolerance  = 90 * time.Second
		greatestTimeTolerance = 45 * time.Second
		floatTolerance        = 0.01
		altitudeTolerance     = 1.0
		azimuthTolerance      = 1.5
		durationTolerance     = 5 * time.Second
	)

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			info := ClosestLocalSolarEclipse(tc.date, tc.lon, tc.lat, tc.height)
			if info.Type != tc.wantType {
				t.Fatalf("type mismatch: got %s want %s", info.Type, tc.wantType)
			}

			assertSolarTimeClose(t, "GreatestEclipse", info.GreatestEclipse, tc.wantGreatest, greatestTimeTolerance)
			assertSolarTimeClose(t, "PartialStart", info.PartialStart, tc.wantPartialStart, partialTimeTolerance)
			assertSolarTimeClose(t, "PartialEnd", info.PartialEnd, tc.wantPartialEnd, partialTimeTolerance)
			assertSolarFloatClose(t, "Magnitude", info.Magnitude, tc.wantMagnitude, floatTolerance)
			assertSolarFloatClose(t, "Obscuration", info.Obscuration, tc.wantObscuration, floatTolerance)
			assertSolarFloatClose(t, "SunAltitude", info.SunAltitude, tc.wantSunAltitude, altitudeTolerance)
			assertSolarFloatClose(t, "SunAzimuth", info.SunAzimuth, tc.wantSunAzimuth, azimuthTolerance)

			if tc.wantCentralDuration > 0 {
				duration := info.CentralEnd.Sub(info.CentralStart)
				assertSolarTimeClose(
					t,
					"CentralDuration",
					time.Unix(0, int64(duration)),
					time.Unix(0, int64(tc.wantCentralDuration)),
					durationTolerance,
				)
			} else if info.HasCentral || !info.CentralStart.IsZero() || !info.CentralEnd.IsZero() {
				t.Fatalf("expected no central phase, got %+v", info)
			}
		})
	}
}

func assertSameLocalSolarEclipse(t *testing.T, name string, got, want LocalSolarEclipseInfo, tolerance time.Duration) {
	t.Helper()
	if got.Type != want.Type {
		t.Fatalf("%s type mismatch: got %s want %s", name, got.Type, want.Type)
	}
	assertSolarTimeClose(t, name+".GreatestEclipse", got.GreatestEclipse, want.GreatestEclipse, tolerance)
}

func assertSolarDurationClose(t *testing.T, name string, got, want, tolerance time.Duration) {
	t.Helper()
	diff := got - want
	if diff < 0 {
		diff = -diff
	}
	if diff > tolerance {
		t.Fatalf("%s mismatch: got %v want %v diff=%v", name, got, want, diff)
	}
}
