package orbit

import (
	"encoding/json"
	"math"
	"os"
	"testing"
	"time"

	"b612.me/astro/basic"
	marspkg "b612.me/astro/mars"
)

const orbitAngleToleranceDeg = 0.02
const orbitDistanceToleranceAU = 3e-4
const orbitVectorToleranceAU = 3e-4
const orbitAstrometricToleranceDeg = 0.02
const orbitAstrometricDistanceToleranceAU = 3e-4
const shanghaiLon = 121.4737
const shanghaiLat = 31.2304
const shanghaiHeightMeters = 20.0

type baselineElements struct {
	Form    string  `json:"form"`
	EpochJD float64 `json:"epoch_jd"`
	A       float64 `json:"a"`
	E       float64 `json:"e"`
	I       float64 `json:"i"`
	Omega   float64 `json:"omega"`
	W       float64 `json:"w"`
	M0      float64 `json:"m0"`
	Q       float64 `json:"q"`
	TpJD    float64 `json:"tp_jd"`
}

type baselineVector struct {
	X float64 `json:"x"`
	Y float64 `json:"y"`
	Z float64 `json:"z"`
}

type baselineHeliocentric struct {
	Vector   baselineVector `json:"vector"`
	Lon      float64        `json:"lon"`
	Lat      float64        `json:"lat"`
	Distance float64        `json:"distance"`
}

type baselineGeocentric struct {
	Vector   baselineVector `json:"vector"`
	RA       float64        `json:"ra"`
	Dec      float64        `json:"dec"`
	Distance float64        `json:"distance"`
}

type baselineObservation struct {
	RA       float64 `json:"ra"`
	Dec      float64 `json:"dec"`
	Distance float64 `json:"distance"`
}

type baselineSample struct {
	JDTT                  float64              `json:"jd_tt"`
	Heliocentric          baselineHeliocentric `json:"heliocentric_j2000"`
	Geocentric            baselineGeocentric   `json:"geocentric_equatorial_j2000"`
	AstrometricGeocentric baselineObservation  `json:"astrometric_geocentric_j2000"`
	ApparentGeocentric    baselineObservation  `json:"apparent_geocentric_equatorial"`
	ApparentTopocentric   baselineObservation  `json:"apparent_topocentric_equatorial"`
}

type baselineObject struct {
	Name     string           `json:"name"`
	Elements baselineElements `json:"elements"`
	Samples  []baselineSample `json:"samples"`
}

func TestGeometricOrbitMatchesJPLBaseline(t *testing.T) {
	objects := loadOrbitBaseline(t)

	var maxHelioVectorDiffAU float64
	var maxHelioLonDiffDeg float64
	var maxHelioLatDiffDeg float64
	var maxGeoVectorDiffAU float64
	var maxGeoRADiffDeg float64
	var maxGeoDecDiffDeg float64
	var maxGeoDistanceDiffAU float64

	for _, object := range objects {
		elements := elementsFromBaseline(object)
		basicElements := toBasicElements(elements)

		for _, sample := range object.Samples {
			date := basic.JDE2DateByZone(basic.TD2UT(sample.JDTT, false), time.UTC, false)

			helVector := basic.OrbitHeliocentricXYZJ2000(sample.JDTT, basicElements)
			helVectorDiff := vectorDiffAU(helVector, sample.Heliocentric.Vector)
			if helVectorDiff > maxHelioVectorDiffAU {
				maxHelioVectorDiffAU = helVectorDiff
			}
			if helVectorDiff > orbitVectorToleranceAU {
				t.Fatalf("%s helio vector mismatch at JD %.1f: diff=%.9f AU", object.Name, sample.JDTT, helVectorDiff)
			}

			hel := HeliocentricEclipticJ2000(date, elements)
			lonDiff := angleDiffAbs(hel.Lon, sample.Heliocentric.Lon)
			if lonDiff > maxHelioLonDiffDeg {
				maxHelioLonDiffDeg = lonDiff
			}
			latDiff := math.Abs(hel.Lat - sample.Heliocentric.Lat)
			if latDiff > maxHelioLatDiffDeg {
				maxHelioLatDiffDeg = latDiff
			}
			if lonDiff > orbitAngleToleranceDeg {
				t.Fatalf("%s helio lon mismatch at JD %.1f: got %.9f want %.9f", object.Name, sample.JDTT, hel.Lon, sample.Heliocentric.Lon)
			}
			if latDiff > orbitAngleToleranceDeg {
				t.Fatalf("%s helio lat mismatch at JD %.1f: got %.9f want %.9f", object.Name, sample.JDTT, hel.Lat, sample.Heliocentric.Lat)
			}
			if distanceDiff := math.Abs(hel.Distance - sample.Heliocentric.Distance); distanceDiff > orbitDistanceToleranceAU {
				t.Fatalf("%s helio distance mismatch at JD %.1f: got %.9f want %.9f", object.Name, sample.JDTT, hel.Distance, sample.Heliocentric.Distance)
			}

			geo := GeocentricEquatorialJ2000(date, elements)
			geoVector := equatorialVectorAU(geo)
			geoVectorDiff := baselineVectorDiffAU(geoVector, sample.Geocentric.Vector)
			if geoVectorDiff > maxGeoVectorDiffAU {
				maxGeoVectorDiffAU = geoVectorDiff
			}
			if geoVectorDiff > orbitVectorToleranceAU {
				t.Fatalf("%s geo vector mismatch at JD %.1f: diff=%.9f AU", object.Name, sample.JDTT, geoVectorDiff)
			}

			raDiff := angleDiffAbs(geo.RA, sample.Geocentric.RA)
			if raDiff > maxGeoRADiffDeg {
				maxGeoRADiffDeg = raDiff
			}
			decDiff := math.Abs(geo.Dec - sample.Geocentric.Dec)
			if decDiff > maxGeoDecDiffDeg {
				maxGeoDecDiffDeg = decDiff
			}
			distanceDiff := math.Abs(geo.Distance - sample.Geocentric.Distance)
			if distanceDiff > maxGeoDistanceDiffAU {
				maxGeoDistanceDiffAU = distanceDiff
			}
			if raDiff > orbitAngleToleranceDeg {
				t.Fatalf("%s geo RA mismatch at JD %.1f: got %.9f want %.9f", object.Name, sample.JDTT, geo.RA, sample.Geocentric.RA)
			}
			if decDiff > orbitAngleToleranceDeg {
				t.Fatalf("%s geo Dec mismatch at JD %.1f: got %.9f want %.9f", object.Name, sample.JDTT, geo.Dec, sample.Geocentric.Dec)
			}
			if distanceDiff > orbitDistanceToleranceAU {
				t.Fatalf("%s geo distance mismatch at JD %.1f: got %.9f want %.9f", object.Name, sample.JDTT, geo.Distance, sample.Geocentric.Distance)
			}
		}
	}

	t.Logf("orbit geometric max diff: helVec=%.9fAU helLon=%.6fdeg helLat=%.6fdeg geoVec=%.9fAU geoRA=%.6fdeg geoDec=%.6fdeg geoDist=%.9fAU",
		maxHelioVectorDiffAU, maxHelioLonDiffDeg, maxHelioLatDiffDeg,
		maxGeoVectorDiffAU, maxGeoRADiffDeg, maxGeoDecDiffDeg, maxGeoDistanceDiffAU)
}

func TestAstrometricGeocentricMatchesJPLBaseline(t *testing.T) {
	maxRADiff, maxDecDiff, maxDistanceDiff := runObservationBaseline(
		t,
		"astrometric",
		func(date time.Time, elements Elements) EquatorialPosition {
			return AstrometricGeocentricEquatorialJ2000(date, elements)
		},
		func(sample baselineSample) baselineObservation {
			return sample.AstrometricGeocentric
		},
	)
	t.Logf("orbit astrometric max diff: RA=%.6fdeg Dec=%.6fdeg Dist=%.9fAU", maxRADiff, maxDecDiff, maxDistanceDiff)
}

func TestApparentGeocentricMatchesJPLBaseline(t *testing.T) {
	maxRADiff, maxDecDiff, maxDistanceDiff := runObservationBaseline(
		t,
		"geocentric apparent",
		func(date time.Time, elements Elements) EquatorialPosition {
			return ApparentGeocentricEquatorial(date, elements)
		},
		func(sample baselineSample) baselineObservation {
			return sample.ApparentGeocentric
		},
	)
	t.Logf("orbit geocentric apparent max diff: RA=%.6fdeg Dec=%.6fdeg Dist=%.9fAU", maxRADiff, maxDecDiff, maxDistanceDiff)
}

func TestApparentTopocentricMatchesJPLBaseline(t *testing.T) {
	maxRADiff, maxDecDiff, maxDistanceDiff := runObservationBaseline(
		t,
		"topocentric apparent",
		func(date time.Time, elements Elements) EquatorialPosition {
			return ApparentTopocentricEquatorial(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
		},
		func(sample baselineSample) baselineObservation {
			return sample.ApparentTopocentric
		},
	)
	t.Logf("orbit topocentric apparent max diff: RA=%.6fdeg Dec=%.6fdeg Dist=%.9fAU", maxRADiff, maxDecDiff, maxDistanceDiff)
}

func runObservationBaseline(
	t *testing.T,
	label string,
	gotFn func(time.Time, Elements) EquatorialPosition,
	wantFn func(baselineSample) baselineObservation,
) (maxRADiff, maxDecDiff, maxDistanceDiff float64) {
	t.Helper()
	objects := loadOrbitBaseline(t)

	for _, object := range objects {
		elements := elementsFromBaseline(object)
		for _, sample := range object.Samples {
			date := basic.JDE2DateByZone(basic.TD2UT(sample.JDTT, false), time.UTC, false)
			got := gotFn(date, elements)
			want := wantFn(sample)
			raDiff := angleDiffAbs(got.RA, want.RA)
			if raDiff > maxRADiff {
				maxRADiff = raDiff
			}
			decDiff := math.Abs(got.Dec - want.Dec)
			if decDiff > maxDecDiff {
				maxDecDiff = decDiff
			}
			distanceDiff := math.Abs(got.Distance - want.Distance)
			if distanceDiff > maxDistanceDiff {
				maxDistanceDiff = distanceDiff
			}
			if raDiff > orbitAstrometricToleranceDeg {
				t.Fatalf("%s %s RA mismatch at JD %.1f: got %.9f want %.9f", object.Name, label, sample.JDTT, got.RA, want.RA)
			}
			if decDiff > orbitAstrometricToleranceDeg {
				t.Fatalf("%s %s Dec mismatch at JD %.1f: got %.9f want %.9f", object.Name, label, sample.JDTT, got.Dec, want.Dec)
			}
			if distanceDiff > orbitAstrometricDistanceToleranceAU {
				t.Fatalf("%s %s distance mismatch at JD %.1f: got %.9f want %.9f", object.Name, label, sample.JDTT, got.Distance, want.Distance)
			}
		}
	}
	return maxRADiff, maxDecDiff, maxDistanceDiff
}

func TestSecularRatesReduceMarsDriftAgainstVSOP(t *testing.T) {
	withRates := Elements{
		EpochJD:  2451545.0,
		A:        1.52371034,
		E:        0.09339410,
		I:        1.84969142,
		Omega:    49.55953891,
		W:        -23.94362959 - 49.55953891,
		M0:       -4.55343205 - (-23.94362959),
		ADot:     0.00001847 / 36525.0,
		EDot:     0.00007882 / 36525.0,
		IDot:     -0.00813131 / 36525.0,
		OmegaDot: -0.29257343 / 36525.0,
		WDot:     (0.44441088 - (-0.29257343)) / 36525.0,
		MDot:     (19140.30268499 - 0.44441088) / 36525.0,
	}
	static := withRates
	static.ADot, static.EDot, static.IDot, static.OmegaDot, static.WDot, static.MDot = 0, 0, 0, 0, 0, 0

	cases := []time.Time{
		time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC),
		time.Date(1950, 1, 1, 0, 0, 0, 0, time.UTC),
		time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC),
		time.Date(2050, 1, 1, 0, 0, 0, 0, time.UTC),
	}

	for _, date := range cases {
		wantRA, wantDec := marspkg.ApparentRaDec(date)
		dynamic := ApparentGeocentricEquatorial(date, withRates)
		stale := ApparentGeocentricEquatorial(date, static)

		dynamicError := angleDiffAbs(dynamic.RA, wantRA) + math.Abs(dynamic.Dec-wantDec)
		staticError := angleDiffAbs(stale.RA, wantRA) + math.Abs(stale.Dec-wantDec)
		if dynamicError > staticError+1e-9 {
			t.Fatalf("%s dynamic elements should improve Mars drift: dynamic=%.9f static=%.9f", date.Format("2006-01-02"), dynamicError, staticError)
		}
		if date.Equal(time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC)) {
			continue
		}
		if staticError/dynamicError < 4 {
			t.Fatalf("%s Mars drift improvement too small: dynamic=%.9f static=%.9f", date.Format("2006-01-02"), dynamicError, staticError)
		}
	}
}

func TestApparentTopocentricFiniteAndReasonable(t *testing.T) {
	elements := Elements{
		EpochJD: 2461000.5,
		A:       2.765615651508659,
		E:       0.07957631994408416,
		I:       10.58788658206854,
		Omega:   80.24963090816965,
		W:       73.29975464616518,
		M0:      231.5397330043706,
	}
	date := time.Date(2025, 11, 21, 0, 0, 0, 0, time.UTC)
	geo := ApparentGeocentricEquatorial(date, elements)
	top := ApparentTopocentricEquatorial(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
	if math.IsNaN(top.RA) || math.IsNaN(top.Dec) || math.IsNaN(top.Distance) {
		t.Fatalf("topocentric result contains NaN: %+v", top)
	}
	if top.Distance <= 0 {
		t.Fatalf("unexpected topocentric distance: %.12f", top.Distance)
	}
	if math.Abs(top.Distance-geo.Distance) > 5e-5 {
		t.Fatalf("topocentric distance shift unexpectedly large: geo=%.12f top=%.12f", geo.Distance, top.Distance)
	}
	if angleDiffAbs(top.RA, geo.RA) > 1 || math.Abs(top.Dec-geo.Dec) > 1 {
		t.Fatalf("topocentric shift unexpectedly large: geo=%+v top=%+v", geo, top)
	}
	if angleDiffAbs(top.RA, geo.RA) == 0 && math.Abs(top.Dec-geo.Dec) == 0 {
		t.Fatalf("topocentric correction should not be identically zero")
	}
}

func TestMeanMotionAndAnomaliesAreFinite(t *testing.T) {
	elements := Elements{
		EpochJD: 2461000.5,
		A:       2.765615651508659,
		E:       0.07957631994408416,
		I:       10.58788658206854,
		Omega:   80.24963090816965,
		W:       73.29975464616518,
		M0:      231.5397330043706,
	}
	date := time.Date(2025, 11, 21, 0, 0, 0, 0, time.UTC)

	meanMotion := MeanMotion(elements)
	if math.IsNaN(meanMotion) || math.IsInf(meanMotion, 0) || meanMotion <= 0 {
		t.Fatalf("invalid mean motion: %.18f", meanMotion)
	}

	meanAnomaly := MeanAnomaly(date, elements)
	trueAnomaly := TrueAnomaly(date, elements)
	for name, value := range map[string]float64{"mean": meanAnomaly, "true": trueAnomaly} {
		if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value >= 360 {
			t.Fatalf("%s anomaly out of range: %.18f", name, value)
		}
	}
}

func TestObservationHelpersMatchTopocentricCoordinates(t *testing.T) {
	elements := sampleObservationElements()
	date := time.Date(2025, 11, 21, 20, 0, 0, 0, time.FixedZone("CST", 8*3600))

	altitude := Altitude(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
	zenith := Zenith(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
	azimuth := Azimuth(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
	hourAngle := HourAngle(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
	topocentric := ApparentTopocentricEquatorial(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)

	for name, value := range map[string]float64{
		"altitude":  altitude,
		"zenith":    zenith,
		"azimuth":   azimuth,
		"hourAngle": hourAngle,
		"ra":        topocentric.RA,
		"dec":       topocentric.Dec,
	} {
		if math.IsNaN(value) || math.IsInf(value, 0) {
			t.Fatalf("%s is not finite: %.18f", name, value)
		}
	}

	jde := basic.Date2JDE(date)
	_, offsetSeconds := date.Zone()
	timezone := float64(offsetSeconds) / 3600.0
	siderealLongitude := normalize360(basic.ApparentSiderealTime(jde-timezone/24.0)*15 + shanghaiLon)
	wantHourAngle := normalize360(siderealLongitude - topocentric.RA)
	if angleDiffAbs(hourAngle, wantHourAngle) > 1e-9 {
		t.Fatalf("hour angle mismatch: got %.12f want %.12f", hourAngle, wantHourAngle)
	}

	wantAltitude := math.Asin(
		math.Sin(shanghaiLat*math.Pi/180)*math.Sin(topocentric.Dec*math.Pi/180)+
			math.Cos(topocentric.Dec*math.Pi/180)*math.Cos(shanghaiLat*math.Pi/180)*math.Cos(wantHourAngle*math.Pi/180),
	) * 180 / math.Pi
	if math.Abs(altitude-wantAltitude) > 1e-9 {
		t.Fatalf("altitude mismatch: got %.12f want %.12f", altitude, wantAltitude)
	}
	wantZenith := 90 - wantAltitude
	if math.Abs(zenith-wantZenith) > 1e-9 {
		t.Fatalf("zenith mismatch: got %.12f want %.12f", zenith, wantZenith)
	}

	wantAzimuth := sphericalAzimuthFromHourAngle(wantHourAngle, topocentric.Dec, shanghaiLat)
	if angleDiffAbs(azimuth, wantAzimuth) > 1e-9 {
		t.Fatalf("azimuth mismatch: got %.12f want %.12f", azimuth, wantAzimuth)
	}
}

func TestCulminationTimeMaximizesAltitude(t *testing.T) {
	elements := sampleObservationElements()
	date := time.Date(2025, 11, 21, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))

	culmination := CulminationTime(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
	before := Altitude(culmination.Add(-5*time.Minute), elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
	at := Altitude(culmination, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
	after := Altitude(culmination.Add(5*time.Minute), elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
	if at < before || at < after {
		t.Fatalf("culmination should maximize altitude: before=%.9f at=%.9f after=%.9f", before, at, after)
	}
	if angleDiffAbs(HourAngle(culmination, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters), 0) > 0.02 {
		t.Fatalf("culmination hour angle should be near zero")
	}
}

func TestRiseSetTimesReachStandardAltitude(t *testing.T) {
	elements := sampleObservationElements()
	date := time.Date(2025, 11, 21, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))

	rise, err := RiseTime(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters, true)
	if err != nil {
		t.Fatalf("rise time failed: %v", err)
	}
	set, err := SetTime(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters, true)
	if err != nil {
		t.Fatalf("set time failed: %v", err)
	}
	culmination := CulminationTime(date, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
	targetAltitude := basic.StandardAltitudePlanet(1, shanghaiHeightMeters, shanghaiLat)

	riseAltitude := Altitude(rise, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
	setAltitude := Altitude(set, elements, shanghaiLon, shanghaiLat, shanghaiHeightMeters)
	if math.Abs(riseAltitude-targetAltitude) > 0.03 {
		t.Fatalf("rise altitude mismatch: got %.9f want %.9f", riseAltitude, targetAltitude)
	}
	if math.Abs(setAltitude-targetAltitude) > 0.03 {
		t.Fatalf("set altitude mismatch: got %.9f want %.9f", setAltitude, targetAltitude)
	}
	if !rise.Before(culmination) {
		t.Fatalf("rise should precede culmination: rise=%s culmination=%s", rise, culmination)
	}
	if !culmination.Before(set) {
		t.Fatalf("culmination should precede set: culmination=%s set=%s", culmination, set)
	}
}

func loadOrbitBaseline(t *testing.T) []baselineObject {
	t.Helper()
	data, err := os.ReadFile("testdata/orbit_baseline.json")
	if err != nil {
		t.Fatalf("read baseline: %v", err)
	}
	var objects []baselineObject
	if err := json.Unmarshal(data, &objects); err != nil {
		t.Fatalf("decode baseline: %v", err)
	}
	return objects
}

func elementsFromBaseline(object baselineObject) Elements {
	if object.Elements.Form == "perihelion" {
		return Elements{
			E:     object.Elements.E,
			I:     object.Elements.I,
			Omega: object.Elements.Omega,
			W:     object.Elements.W,
			Q:     object.Elements.Q,
			TpJD:  object.Elements.TpJD,
		}
	}
	return Elements{
		EpochJD: object.Elements.EpochJD,
		A:       object.Elements.A,
		E:       object.Elements.E,
		I:       object.Elements.I,
		Omega:   object.Elements.Omega,
		W:       object.Elements.W,
		M0:      object.Elements.M0,
	}
}

func vectorDiffAU(vector basic.Vector3, want baselineVector) float64 {
	dx := vector[0] - want.X
	dy := vector[1] - want.Y
	dz := vector[2] - want.Z
	return math.Sqrt(dx*dx + dy*dy + dz*dz)
}

func baselineVectorDiffAU(got, want baselineVector) float64 {
	dx := got.X - want.X
	dy := got.Y - want.Y
	dz := got.Z - want.Z
	return math.Sqrt(dx*dx + dy*dy + dz*dz)
}

func angleDiffAbs(got, want float64) float64 {
	diff := math.Abs(got - want)
	if diff > 180 {
		diff = 360 - diff
	}
	return diff
}

func equatorialVectorAU(position EquatorialPosition) baselineVector {
	raRad := position.RA * math.Pi / 180
	decRad := position.Dec * math.Pi / 180
	cosDec := math.Cos(decRad)
	return baselineVector{
		X: position.Distance * cosDec * math.Cos(raRad),
		Y: position.Distance * cosDec * math.Sin(raRad),
		Z: position.Distance * math.Sin(decRad),
	}
}

func sampleObservationElements() Elements {
	return Elements{
		EpochJD: 2461000.5,
		A:       2.765615651508659,
		E:       0.07957631994408416,
		I:       10.58788658206854,
		Omega:   80.24963090816965,
		W:       73.29975464616518,
		M0:      231.5397330043706,
	}
}

func normalize360(value float64) float64 {
	value = math.Mod(value, 360)
	if value < 0 {
		value += 360
	}
	return value
}

func sphericalAzimuthFromHourAngle(hourAngle, dec, lat float64) float64 {
	tanAzimuth := math.Sin(hourAngle*math.Pi/180) / (math.Cos(hourAngle*math.Pi/180)*math.Sin(lat*math.Pi/180) - math.Tan(dec*math.Pi/180)*math.Cos(lat*math.Pi/180))
	azimuth := math.Atan(tanAzimuth) * 180 / math.Pi
	if azimuth < 0 {
		if hourAngle/15 < 12 {
			return azimuth + 360
		}
		return azimuth + 180
	}
	if hourAngle/15 < 12 {
		return azimuth + 180
	}
	return azimuth
}
