package geojson

import (
	"fmt"
	"time"

	"b612.me/astro/basic"
	eclipsecore "b612.me/astro/eclipse"
	"b612.me/astro/internal/geodata"
)

func validateSolarEclipseInput(
	partial eclipsecore.SolarEclipsePartialFootprintsInfo,
	central *eclipsecore.SolarEclipsePath,
) error {
	info := partial.Eclipse
	if info.GreatestEclipse.IsZero() {
		return fmt.Errorf("geojson: solar eclipse greatest time is required")
	}
	if !info.HasPartial {
		return fmt.Errorf("geojson: solar eclipse must contain a partial phase")
	}
	if info.PartialBeginOnEarth.IsZero() || info.PartialEndOnEarth.IsZero() {
		return fmt.Errorf("geojson: solar eclipse partial contact times are required")
	}
	if !info.PartialBeginOnEarth.Before(info.GreatestEclipse) ||
		!info.GreatestEclipse.Before(info.PartialEndOnEarth) {
		return fmt.Errorf("geojson: solar eclipse times must be ordered partial begin, greatest, partial end")
	}
	if err := validateSolarPathPoint("solar greatest", eclipsecore.SolarEclipsePathPoint{
		Time: info.GreatestEclipse, Longitude: info.GreatestLongitude, Latitude: info.GreatestLatitude,
	}); err != nil {
		return err
	}
	previous := time.Time{}
	for index, footprint := range partial.Footprints {
		if footprint.Time.IsZero() {
			return fmt.Errorf("geojson: solar partial footprint %d time is required", index)
		}
		if !previous.IsZero() && !footprint.Time.After(previous) {
			return fmt.Errorf("geojson: solar partial footprint times must be strictly increasing")
		}
		if footprint.Time.Before(info.PartialBeginOnEarth) || footprint.Time.After(info.PartialEndOnEarth) {
			return fmt.Errorf("geojson: solar partial footprint %d time is outside the partial interval", index)
		}
		previous = footprint.Time
	}
	if central == nil {
		return nil
	}
	if !central.Eclipse.GreatestEclipse.Equal(info.GreatestEclipse) ||
		central.Eclipse.Type != info.Type || central.Eclipse.Model != info.Model {
		return fmt.Errorf("geojson: partial footprints and central path describe different eclipses")
	}
	if central.Eclipse.CentralBeginOnEarth.IsZero() || central.Eclipse.CentralEndOnEarth.IsZero() ||
		!central.Eclipse.CentralBeginOnEarth.Before(central.Eclipse.GreatestEclipse) ||
		!central.Eclipse.GreatestEclipse.Before(central.Eclipse.CentralEndOnEarth) {
		return fmt.Errorf("geojson: solar central path contact times are invalid")
	}
	if err := validateSolarPathPoint("solar central greatest", central.Greatest); err != nil {
		return err
	}
	if !central.Greatest.Time.Equal(central.Eclipse.GreatestEclipse) {
		return fmt.Errorf("geojson: solar central greatest time does not match eclipse greatest")
	}
	if err := validateSolarPathSeries("solar center line", central.CenterLine, true); err != nil {
		return err
	}
	if central.Greatest.Time.Before(central.CenterLine[0].Time) ||
		central.Greatest.Time.After(central.CenterLine[len(central.CenterLine)-1].Time) {
		return fmt.Errorf("geojson: solar greatest time is outside the center-line interval")
	}
	if central.CenterLine[0].Time.Before(central.Eclipse.CentralBeginOnEarth) ||
		central.CenterLine[len(central.CenterLine)-1].Time.After(central.Eclipse.CentralEndOnEarth) {
		return fmt.Errorf("geojson: solar center line is outside the central interval")
	}
	if len(central.NorthernLimit) != len(central.SouthernLimit) {
		return fmt.Errorf("geojson: solar central limits must have the same sample count")
	}
	if len(central.NorthernLimit) > 0 {
		if err := validateSolarPathSeries("solar northern limit", central.NorthernLimit, true); err != nil {
			return err
		}
		if err := validateSolarPathSeries("solar southern limit", central.SouthernLimit, true); err != nil {
			return err
		}
		for index := range central.NorthernLimit {
			if !central.NorthernLimit[index].Time.Equal(central.SouthernLimit[index].Time) {
				return fmt.Errorf("geojson: solar central limit sample %d times must match", index)
			}
		}
	}
	return nil
}

func validateSolarPathSeries(name string, points []eclipsecore.SolarEclipsePathPoint, required bool) error {
	if required && len(points) < 2 {
		return fmt.Errorf("geojson: %s requires at least two points", name)
	}
	previous := time.Time{}
	for index, point := range points {
		if err := validateSolarPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil {
			return err
		}
		if !previous.IsZero() && !point.Time.After(previous) {
			return fmt.Errorf("geojson: %s times must be strictly increasing", name)
		}
		previous = point.Time
	}
	return nil
}

func validateSolarPathPoint(name string, point eclipsecore.SolarEclipsePathPoint) error {
	if point.Time.IsZero() {
		return fmt.Errorf("geojson: %s time is required", name)
	}
	if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
		return fmt.Errorf("geojson: %s: %w", name, err)
	}
	if !finiteGeoJSON(point.SunAltitude) || point.SunAltitude < -90 || point.SunAltitude > 90 {
		return fmt.Errorf("geojson: %s sun altitude must be finite and within [-90, 90]", name)
	}
	if !finiteGeoJSON(point.WidthKM) || point.WidthKM < 0 {
		return fmt.Errorf("geojson: %s width must be finite and non-negative", name)
	}
	return nil
}

func validateLunarEclipseInfo(info eclipsecore.LunarEclipseInfo) error {
	if !info.HasPenumbral || info.PenumbralStart.IsZero() || info.PenumbralEnd.IsZero() {
		return fmt.Errorf("geojson: lunar eclipse penumbral contact times are required")
	}
	if info.Maximum.IsZero() {
		return fmt.Errorf("geojson: lunar eclipse greatest time is required")
	}
	if info.Type != eclipsecore.LunarEclipsePenumbral && info.Type != eclipsecore.LunarEclipsePartial &&
		info.Type != eclipsecore.LunarEclipseTotal {
		return fmt.Errorf("geojson: lunar eclipse type is invalid")
	}
	switch info.Type {
	case eclipsecore.LunarEclipsePenumbral:
		if info.HasPartial || info.HasTotal {
			return fmt.Errorf("geojson: penumbral eclipse cannot contain partial or total phases")
		}
	case eclipsecore.LunarEclipsePartial:
		if !info.HasPartial || info.HasTotal {
			return fmt.Errorf("geojson: partial eclipse must contain only a partial phase")
		}
	case eclipsecore.LunarEclipseTotal:
		if !info.HasPartial || !info.HasTotal {
			return fmt.Errorf("geojson: total eclipse must contain partial and total phases")
		}
	}
	if !info.HasPartial && (!info.PartialStart.IsZero() || !info.PartialEnd.IsZero()) {
		return fmt.Errorf("geojson: partial contact times require a partial phase")
	}
	if !info.HasTotal && (!info.TotalStart.IsZero() || !info.TotalEnd.IsZero()) {
		return fmt.Errorf("geojson: total contact times require a total phase")
	}
	ordered := []time.Time{info.PenumbralStart}
	if info.HasPartial {
		if info.PartialStart.IsZero() || info.PartialEnd.IsZero() {
			return fmt.Errorf("geojson: lunar eclipse partial contact times are required")
		}
		ordered = append(ordered, info.PartialStart)
	}
	if info.HasTotal {
		if info.TotalStart.IsZero() || info.TotalEnd.IsZero() {
			return fmt.Errorf("geojson: lunar eclipse total contact times are required")
		}
		ordered = append(ordered, info.TotalStart)
	}
	ordered = append(ordered, info.Maximum)
	if info.HasTotal {
		ordered = append(ordered, info.TotalEnd)
	}
	if info.HasPartial {
		ordered = append(ordered, info.PartialEnd)
	}
	ordered = append(ordered, info.PenumbralEnd)
	for index := 1; index < len(ordered); index++ {
		if !ordered[index-1].Before(ordered[index]) {
			return fmt.Errorf("geojson: lunar eclipse contact times are not strictly ordered")
		}
	}
	return nil
}

const (
	solarEclipseEvent = "solar-eclipse"
	lunarEclipseEvent = "lunar-eclipse"

	defaultLunarBoundaryPoints = 360
	minimumLunarBoundaryPoints = 12
	maximumLunarBoundaryPoints = 1440
)

// MarshalSolarEclipse 将日食半影足迹和可选中心食带编码为 GeoJSON。
// MarshalSolarEclipse encodes penumbral footprints and an optional central path as GeoJSON.
func MarshalSolarEclipse(
	partial eclipsecore.SolarEclipsePartialFootprintsInfo,
	central *eclipsecore.SolarEclipsePath,
) ([]byte, error) {
	return marshalSolarEclipse(partial, central, nil)
}

// MarshalSolarEclipseWithTimeMarkers 编码日食，并沿中心线按固定间隔追加 Point 要素；已有要素不变，标记标签使用 options.Location，时间值保持 UTC。
// MarshalSolarEclipseWithTimeMarkers encodes a solar eclipse and adds Point Features at regular intervals along the central line. Existing features are unchanged; marker labels use options.Location while time values stay UTC.
func MarshalSolarEclipseWithTimeMarkers(
	partial eclipsecore.SolarEclipsePartialFootprintsInfo,
	central *eclipsecore.SolarEclipsePath,
	options TimeMarkerOptions,
) ([]byte, error) {
	return marshalSolarEclipse(partial, central, &options)
}

func marshalSolarEclipse(
	partial eclipsecore.SolarEclipsePartialFootprintsInfo,
	central *eclipsecore.SolarEclipsePath,
	markerOptions *TimeMarkerOptions,
) ([]byte, error) {
	if markerOptions != nil {
		if err := validateTimeMarkerOptions(*markerOptions); err != nil {
			return nil, err
		}
	}
	if len(partial.Footprints) == 0 {
		return nil, fmt.Errorf("geojson: solar eclipse has no partial footprints")
	}
	if err := validateSolarEclipseInput(partial, central); err != nil {
		return nil, err
	}

	properties := map[string]interface{}{
		"eclipse_type": string(partial.Eclipse.Type),
		"model":        string(partial.Eclipse.Model),
	}
	features := make([]feature, 0, len(partial.Footprints)+8)
	for _, footprint := range partial.Footprints {
		polygon, err := solarPartialFootprintPolygon(footprint)
		if err != nil {
			return nil, err
		}
		footprintProperties := cloneProperties(properties)
		footprintProperties["time"] = formatTime(footprint.Time)
		footprintProperties["source_boundary_closed"] = footprint.Closed
		if len(polygon) == 1 {
			value, pointErr := pointGeometry(polygon[0].Longitude, polygon[0].Latitude)
			if pointErr != nil {
				return nil, fmt.Errorf("geojson: solar partial footprint at %s: %w", formatTime(footprint.Time), pointErr)
			}
			features = append(features, newFeature(
				solarEclipseEvent, "partial-footprint", value, footprintProperties,
			))
			continue
		}
		value, err := multiPolygonGeometry([][]geodata.GeoPoint{polygon})
		if err != nil {
			return nil, fmt.Errorf("geojson: solar partial footprint at %s: %w", formatTime(footprint.Time), err)
		}
		features = append(features, newFeature(
			solarEclipseEvent, "partial-footprint", value, footprintProperties,
		))
	}

	if central != nil {
		if len(central.NorthernLimit) > 0 {
			band, err := pairedLimitPolygon(central.NorthernLimit, central.SouthernLimit)
			if err != nil {
				return nil, fmt.Errorf("geojson: solar central band: %w", err)
			}
			value, err := multiPolygonGeometry([][]geodata.GeoPoint{band})
			if err != nil {
				return nil, fmt.Errorf("geojson: solar central band: %w", err)
			}
			features = append(features, newFeature(
				solarEclipseEvent, "central-band", value, cloneProperties(properties),
			))
		}

		var err error
		features, err = appendSolarPathLine(features, "center-line", central.CenterLine, properties)
		if err != nil {
			return nil, err
		}
		if len(central.NorthernLimit) > 0 {
			features, err = appendSolarPathLine(features, "north-limit", central.NorthernLimit, properties)
			if err != nil {
				return nil, err
			}
			features, err = appendSolarPathLine(features, "south-limit", central.SouthernLimit, properties)
			if err != nil {
				return nil, err
			}
		}
		if markerOptions != nil {
			features, err = appendTimeMarkerFeatures(
				features,
				solarEclipseEvent,
				"center-line",
				solarPathSamples(central.CenterLine),
				*markerOptions,
			)
			if err != nil {
				return nil, err
			}
		}
	}

	greatest := pathSample{
		Time:      partial.Eclipse.GreatestEclipse,
		Longitude: partial.Eclipse.GreatestLongitude,
		Latitude:  partial.Eclipse.GreatestLatitude,
	}
	greatestProperties := solarEclipseMetadata(partial.Eclipse)
	if central != nil {
		greatest = solarPathSample(central.Greatest)
		greatestProperties["width_km"] = central.Greatest.WidthKM
		greatestProperties["sun_altitude_deg"] = central.Greatest.SunAltitude
	}
	var err error
	features, err = appendPointFeature(
		features, solarEclipseEvent, "greatest", greatest, greatestProperties,
	)
	if err != nil {
		return nil, err
	}
	return marshalFeatureCollection(features)
}

// MarshalLunarEclipse 将月食 P1/P4 可见半球和地平线边界编码为 GeoJSON。
// MarshalLunarEclipse encodes the P1/P4 visible hemispheres and horizon boundaries as GeoJSON.
// boundaryPoints 小于等于零时使用 360；其他值限制在 [12, 1440]。
// boundaryPoints values <= 0 use 360; other values are clamped to [12, 1440].
func MarshalLunarEclipse(info eclipsecore.LunarEclipseInfo, boundaryPoints int) ([]byte, error) {
	return marshalLunarEclipse(info, boundaryPoints, nil)
}

// MarshalLunarEclipseWithTimeMarkers 编码月食，并沿半影开始到结束的月下点轨迹追加 Point 要素。
// MarshalLunarEclipseWithTimeMarkers encodes a lunar eclipse and adds Point Features along the sublunar track from penumbral start through end.
// 已有要素保持不变；标记标签使用 options.Location，时间值保持 UTC。
// Existing features are unchanged; marker labels use options.Location while time values stay UTC.
func MarshalLunarEclipseWithTimeMarkers(
	info eclipsecore.LunarEclipseInfo,
	boundaryPoints int,
	options TimeMarkerOptions,
) ([]byte, error) {
	return marshalLunarEclipse(info, boundaryPoints, &options)
}

func marshalLunarEclipse(
	info eclipsecore.LunarEclipseInfo,
	boundaryPoints int,
	markerOptions *TimeMarkerOptions,
) ([]byte, error) {
	if markerOptions != nil {
		if err := validateTimeMarkerOptions(*markerOptions); err != nil {
			return nil, err
		}
	}
	if err := validateLunarEclipseInfo(info); err != nil {
		return nil, err
	}
	boundaryPoints = normalizeLunarBoundaryPoints(boundaryPoints)
	properties := map[string]interface{}{
		"eclipse_type":    string(info.Type),
		"boundary_points": boundaryPoints,
	}
	features := make([]feature, 0, 5)

	contacts := []struct {
		role        string
		horizonRole string
		time        time.Time
	}{
		{role: "visible-at-p1", horizonRole: "p1-horizon", time: info.PenumbralStart},
		{role: "visible-at-p4", horizonRole: "p4-horizon", time: info.PenumbralEnd},
	}
	for _, contact := range contacts {
		center := lunarSubpoint(contact.time)
		polygons := geodata.VisibleHemispherePolygons(
			center, geodata.ProjectionEquirectangular, boundaryPoints,
		)
		value, err := multiPolygonGeometryFromFragments(polygons)
		if err != nil {
			return nil, fmt.Errorf("geojson: %s: %w", contact.role, err)
		}
		contactProperties := cloneProperties(properties)
		contactProperties["time"] = formatTime(contact.time)
		features = append(features, newFeature(
			lunarEclipseEvent, contact.role, value, contactProperties,
		))

		horizon := geodata.SphericalCircle(center, 90, boundaryPoints)
		horizonValue, err := geoMultiLineGeometry(horizon, true)
		if err != nil {
			return nil, fmt.Errorf("geojson: %s: %w", contact.horizonRole, err)
		}
		features = append(features, newFeature(
			lunarEclipseEvent,
			contact.horizonRole,
			horizonValue,
			map[string]interface{}{
				"eclipse_type": string(info.Type),
				"time":         formatTime(contact.time),
			},
		))
	}

	maximum := lunarSubpoint(info.Maximum)
	features, err := appendPointFeature(
		features,
		lunarEclipseEvent,
		"greatest",
		pathSample{Time: info.Maximum, Longitude: maximum.Longitude, Latitude: maximum.Latitude},
		lunarEclipseMetadata(info),
	)
	if err != nil {
		return nil, err
	}
	if markerOptions != nil {
		markers, markerErr := lunarEclipseTimeMarkerSamples(info, *markerOptions)
		if markerErr != nil {
			return nil, markerErr
		}
		features, err = appendTimeMarkerPointFeatures(
			features,
			lunarEclipseEvent,
			"sublunar-track",
			markers,
			markerOptions.Location,
		)
		if err != nil {
			return nil, err
		}
	}
	return marshalFeatureCollection(features)
}

func solarPartialFootprintPolygon(
	footprint eclipsecore.SolarEclipsePartialFootprint,
) ([]geodata.GeoPoint, error) {
	if footprint.Time.IsZero() {
		return nil, fmt.Errorf("geojson: solar partial footprint time is required")
	}
	segments := make([][]geodata.GeoPoint, 0, len(footprint.Boundaries))
	for _, source := range footprint.Boundaries {
		segment := make([]geodata.GeoPoint, len(source))
		for index, point := range source {
			if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
				return nil, err
			}
			segment[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
		}
		segments = append(segments, segment)
	}
	boundary := geodata.JoinPolylineSegments(segments)
	boundary = openRing(boundary)
	if len(boundary) == 1 && !footprint.Closed {
		return boundary, nil
	}
	minimumPoints := 3
	if !footprint.Closed {
		minimumPoints = 2
	}
	if len(boundary) < minimumPoints {
		return nil, fmt.Errorf("geojson: solar partial footprint boundary is incomplete")
	}
	polygon := append([]geodata.GeoPoint(nil), boundary...)
	if !footprint.Closed {
		terminator := geodata.SphericalCircle(solarSubsolarPoint(footprint.Time), 90, 360)
		arc := geodata.ShortestCircleArc(terminator, boundary[len(boundary)-1], boundary[0])
		if len(arc) > 1 {
			polygon = append(polygon, arc[1:]...)
		}
	}
	if len(openRing(polygon)) < 3 {
		return nil, fmt.Errorf("geojson: solar partial footprint polygon is incomplete")
	}
	return polygon, nil
}

func pairedLimitPolygon(
	northern, southern []eclipsecore.SolarEclipsePathPoint,
) ([]geodata.GeoPoint, error) {
	if len(northern) != len(southern) {
		return nil, fmt.Errorf("paired limits must have the same sample count")
	}
	count := len(northern)
	if count < 2 {
		return nil, fmt.Errorf("paired limits require at least two points per side")
	}
	for index := range northern {
		if northern[index].Time.IsZero() || southern[index].Time.IsZero() {
			return nil, fmt.Errorf("paired limit sample %d time is required", index)
		}
		if !northern[index].Time.Equal(southern[index].Time) {
			return nil, fmt.Errorf("paired limit sample %d times must match", index)
		}
	}
	polygon := make([]geodata.GeoPoint, 0, 2*count)
	for _, point := range northern[:count] {
		polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
	}
	for index := count - 1; index >= 0; index-- {
		point := southern[index]
		polygon = append(polygon, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
	}
	return polygon, nil
}

func appendSolarPathLine(
	features []feature,
	role string,
	points []eclipsecore.SolarEclipsePathPoint,
	properties map[string]interface{},
) ([]feature, error) {
	samples := make([]pathSample, len(points))
	for index, point := range points {
		samples[index] = solarPathSample(point)
	}
	return appendTimedLineFeature(features, solarEclipseEvent, role, samples, properties)
}

func solarPathSamples(points []eclipsecore.SolarEclipsePathPoint) []pathSample {
	samples := make([]pathSample, len(points))
	for index, point := range points {
		samples[index] = solarPathSample(point)
	}
	return samples
}

func solarPathSample(point eclipsecore.SolarEclipsePathPoint) pathSample {
	return pathSample{Time: point.Time, Longitude: point.Longitude, Latitude: point.Latitude}
}

func solarEclipseMetadata(info eclipsecore.SolarEclipseInfo) map[string]interface{} {
	return map[string]interface{}{
		"eclipse_type":           string(info.Type),
		"model":                  string(info.Model),
		"centrality":             string(info.Centrality),
		"magnitude":              info.Magnitude,
		"gamma":                  info.Gamma,
		"path_width_km":          info.PathWidthKM,
		"partial_begin_on_earth": formatTime(info.PartialBeginOnEarth),
		"partial_end_on_earth":   formatTime(info.PartialEndOnEarth),
		"central_begin_on_earth": formatTime(info.CentralBeginOnEarth),
		"central_end_on_earth":   formatTime(info.CentralEndOnEarth),
	}
}

func lunarEclipseMetadata(info eclipsecore.LunarEclipseInfo) map[string]interface{} {
	return map[string]interface{}{
		"eclipse_type":        string(info.Type),
		"penumbral_magnitude": info.PenumbralMagnitude,
		"umbral_magnitude":    info.UmbralMagnitude,
		"penumbral_start":     formatTime(info.PenumbralStart),
		"partial_start":       formatTime(info.PartialStart),
		"total_start":         formatTime(info.TotalStart),
		"total_end":           formatTime(info.TotalEnd),
		"partial_end":         formatTime(info.PartialEnd),
		"penumbral_end":       formatTime(info.PenumbralEnd),
	}
}

func solarSubsolarPoint(value time.Time) geodata.GeoPoint {
	ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true)
	ra, dec := basic.HSunApparentRaDec(ttJDE)
	utJDE := basic.TD2UT(ttJDE, false)
	longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(utJDE)*15)
	return geodata.GeoPoint{Longitude: longitude, Latitude: dec}
}

func lunarSubpoint(value time.Time) geodata.GeoPoint {
	ttJDE := basic.TD2UT(basic.Date2JDE(value.UTC()), true)
	ra, dec := basic.HMoonTrueRaDec(ttJDE)
	utJDE := basic.TD2UT(ttJDE, false)
	longitude := normalizeLongitude(ra - basic.ApparentSiderealTime(utJDE)*15)
	return geodata.GeoPoint{Longitude: longitude, Latitude: dec}
}

func lunarEclipseTimeMarkerSamples(
	info eclipsecore.LunarEclipseInfo,
	options TimeMarkerOptions,
) ([]pathSample, error) {
	step, err := normalizeTimeMarkerStep(options.Step)
	if err != nil {
		return nil, fmt.Errorf("geojson: lunar eclipse time markers: %w", err)
	}
	location := normalizeTimeMarkerLocation(options.Location)
	start, end := info.PenumbralStart, info.PenumbralEnd
	capacity, err := timeMarkerCapacity(start, end, step, location)
	if err != nil {
		return nil, fmt.Errorf("geojson: lunar eclipse time markers: %w", err)
	}
	current := firstTimeMarkerAfter(start, step, location)
	markers := make([]pathSample, 0, capacity)
	for current.Before(end) {
		point := lunarSubpoint(current)
		markers = append(markers, pathSample{
			Time:      current,
			Longitude: point.Longitude,
			Latitude:  point.Latitude,
		})
		current = current.Add(step)
	}
	return markers, nil
}

func normalizeLunarBoundaryPoints(value int) int {
	if value <= 0 {
		return defaultLunarBoundaryPoints
	}
	if value < minimumLunarBoundaryPoints {
		return minimumLunarBoundaryPoints
	}
	if value > maximumLunarBoundaryPoints {
		return maximumLunarBoundaryPoints
	}
	return value
}
