// Package geojson 将日月食和月掩地理结果编码为 RFC 7946 GeoJSON FeatureCollections。
// 坐标是 WGS84 经度和纬度，单位为度；地图投影和样式由应用处理。
// Package geojson encodes eclipse and lunar-occultation geographic results as RFC 7946 GeoJSON FeatureCollections.
// Coordinates are WGS84 longitude and latitude in degrees; map projection and styling remain application concerns.
//
// 每个要素都包含 event 和 role 属性。带时间的 MultiLineString 要素还包含与坐标段对齐的嵌套 times 数组。
// Times 编码为 UTC RFC 3339 字符串；路径采样由上游日月食和月掩选项控制后再传入本包。
// WithTimeMarkers 变体还会追加 role 为 time-marker 的 Point 要素，标签按请求地点格式化。
// Every feature has event and role properties. Timed MultiLineString features also contain a nested times array aligned with their coordinate segments.
// Times are encoded as UTC RFC 3339 strings. Path sampling is controlled by the source eclipse and occultation options before values reach this package.
// The WithTimeMarkers variants additionally append Point Features whose role is time-marker and whose label is formatted for the requested location.
package geojson

import (
	"encoding/json"
	"fmt"
	"math"
	"time"

	"b612.me/astro/internal/geodata"
)

const (
	featureCollectionType  = "FeatureCollection"
	minimumTimeMarkerStep  = time.Minute
	maximumTimeMarkerCount = 1440
)

type featureCollection struct {
	Type     string    `json:"type"`
	Features []feature `json:"features"`
}

type feature struct {
	Type       string                 `json:"type"`
	Properties map[string]interface{} `json:"properties"`
	Geometry   geometry               `json:"geometry"`
}

type geometry struct {
	Type        string      `json:"type"`
	Coordinates interface{} `json:"coordinates"`
}

type pathSample struct {
	Time      time.Time
	Longitude float64
	Latitude  float64
}

// TimeMarkerOptions 控制供地图客户端绘制路径时间标签的可选 Point Feature。
// TimeMarkerOptions controls optional Point Features used by map clients to draw time labels along a moving event path.
// Step 控制标记间隔；零值使用 30 分钟，并对齐到下一个本地整点边界。
// Step controls the marker interval; zero uses 30 minutes and aligns markers to the next local clock boundary.
// Location 控制 HH:MM 标签，默认 UTC；底层 time 属性仍为 UTC RFC 3339。
// Location controls the HH:MM label and defaults to UTC. The underlying time property remains UTC RFC 3339.
// 正 Step 至少为一分钟，单次导出最多 1440 个标记。
// Positive Step values must be at least one minute, and one export is limited to 1440 markers.
type TimeMarkerOptions struct {
	// Step 是时间标记之间的间隔；零值使用 30 分钟。
	// Step is the interval between time markers; zero uses 30 minutes.
	Step time.Duration
	// Location 是格式化 HH:MM 标签时使用的时区；nil 使用 UTC。
	// Location is the timezone used to format HH:MM labels; nil uses UTC.
	Location *time.Location
}

func marshalFeatureCollection(features []feature) ([]byte, error) {
	if len(features) == 0 {
		return nil, fmt.Errorf("geojson: no geographic features")
	}
	value, err := json.Marshal(featureCollection{Type: featureCollectionType, Features: features})
	if err != nil {
		return nil, fmt.Errorf("geojson: encode feature collection: %w", err)
	}
	return value, nil
}

func newFeature(event, role string, value geometry, properties map[string]interface{}) feature {
	if properties == nil {
		properties = make(map[string]interface{})
	}
	properties["event"] = event
	properties["role"] = role
	return feature{Type: "Feature", Properties: properties, Geometry: value}
}

func appendTimedLineFeature(
	features []feature,
	event, role string,
	points []pathSample,
	properties map[string]interface{},
) ([]feature, error) {
	value, times, err := timedMultiLineGeometry(points)
	if err != nil {
		return nil, fmt.Errorf("geojson: %s: %w", role, err)
	}
	properties = cloneProperties(properties)
	properties["times"] = times
	return append(features, newFeature(event, role, value, properties)), nil
}

func appendPointFeature(
	features []feature,
	event, role string,
	point pathSample,
	properties map[string]interface{},
) ([]feature, error) {
	if point.Time.IsZero() {
		return nil, fmt.Errorf("geojson: %s: point time is required", role)
	}
	value, err := pointGeometry(point.Longitude, point.Latitude)
	if err != nil {
		return nil, fmt.Errorf("geojson: %s: %w", role, err)
	}
	properties = cloneProperties(properties)
	properties["time"] = formatTime(point.Time)
	return append(features, newFeature(event, role, value, properties)), nil
}

func appendTimeMarkerFeatures(
	features []feature,
	event, sourceRole string,
	points []pathSample,
	options TimeMarkerOptions,
) ([]feature, error) {
	markers, err := timeMarkerPoints(points, options)
	if err != nil {
		return nil, fmt.Errorf("geojson: %s time markers: %w", sourceRole, err)
	}
	return appendTimeMarkerPointFeatures(features, event, sourceRole, markers, options.Location)
}

func validateTimeMarkerOptions(options TimeMarkerOptions) error {
	if _, err := normalizeTimeMarkerStep(options.Step); err != nil {
		return fmt.Errorf("geojson: time markers: %w", err)
	}
	return nil
}

func appendTimeMarkerPointFeatures(
	features []feature,
	event, sourceRole string,
	markers []pathSample,
	location *time.Location,
) ([]feature, error) {
	location = normalizeTimeMarkerLocation(location)
	for _, marker := range markers {
		properties := map[string]interface{}{
			"source_role": sourceRole,
			"label":       marker.Time.In(location).Format("15:04"),
		}
		var err error
		features, err = appendPointFeature(features, event, "time-marker", marker, properties)
		if err != nil {
			return nil, err
		}
	}
	return features, nil
}

func timeMarkerPoints(points []pathSample, options TimeMarkerOptions) ([]pathSample, error) {
	step, err := normalizeTimeMarkerStep(options.Step)
	if err != nil {
		return nil, err
	}
	if len(points) < 2 {
		return nil, nil
	}
	location := normalizeTimeMarkerLocation(options.Location)
	for index, point := range points {
		if point.Time.IsZero() {
			return nil, fmt.Errorf("sample %d has a zero time", index)
		}
		if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
			return nil, fmt.Errorf("sample %d: %w", index, err)
		}
		if index > 0 && !point.Time.After(points[index-1].Time) {
			return nil, fmt.Errorf("sample times must be strictly increasing")
		}
	}
	start, end := points[0].Time, points[len(points)-1].Time
	capacity, err := timeMarkerCapacity(start, end, step, location)
	if err != nil {
		return nil, err
	}
	current := firstTimeMarkerAfter(start, step, location)
	markers := make([]pathSample, 0, capacity)
	segment := 1
	for current.Before(end) {
		for segment < len(points) && points[segment].Time.Before(current) {
			segment++
		}
		if segment >= len(points) {
			break
		}
		a, b := points[segment-1], points[segment]
		span := b.Time.Sub(a.Time)
		if span > 0 {
			fraction := float64(current.Sub(a.Time)) / float64(span)
			markers = append(markers, interpolateTimeMarker(a, b, fraction, current))
		}
		current = current.Add(step)
	}
	return markers, nil
}

func normalizeTimeMarkerStep(value time.Duration) (time.Duration, error) {
	if value < 0 {
		return 0, fmt.Errorf("step must be zero or positive")
	}
	if value == 0 {
		return 30 * time.Minute, nil
	}
	if value < minimumTimeMarkerStep {
		return 0, fmt.Errorf("step must be at least %s", minimumTimeMarkerStep)
	}
	return value, nil
}

func timeMarkerCapacity(start, end time.Time, step time.Duration, location *time.Location) (int, error) {
	if !end.After(start) {
		return 0, fmt.Errorf("marker time range must be strictly increasing")
	}
	first := firstTimeMarkerAfter(start, step, normalizeTimeMarkerLocation(location))
	if !first.Before(end) {
		return 0, nil
	}
	count := 1 + (end.Sub(first)-time.Nanosecond)/step
	if count > maximumTimeMarkerCount {
		return 0, fmt.Errorf("time marker count %d exceeds limit %d", count, maximumTimeMarkerCount)
	}
	return int(count), nil
}

func firstTimeMarkerAfter(value time.Time, step time.Duration, location *time.Location) time.Time {
	local := value.In(location)
	dayStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
	elapsed := local.Sub(dayStart)
	return dayStart.Add((elapsed/step + 1) * step)
}

func interpolateTimeMarker(a, b pathSample, fraction float64, value time.Time) pathSample {
	deltaLongitude := b.Longitude - a.Longitude
	if deltaLongitude > 180 {
		deltaLongitude -= 360
	} else if deltaLongitude < -180 {
		deltaLongitude += 360
	}
	return pathSample{
		Time:      value,
		Longitude: normalizeLongitude(a.Longitude + fraction*deltaLongitude),
		Latitude:  a.Latitude + fraction*(b.Latitude-a.Latitude),
	}
}

func normalizeTimeMarkerLocation(location *time.Location) *time.Location {
	if location == nil {
		return time.UTC
	}
	return location
}

func cloneProperties(source map[string]interface{}) map[string]interface{} {
	result := make(map[string]interface{}, len(source)+2)
	for key, value := range source {
		result[key] = value
	}
	return result
}

func pointGeometry(longitude, latitude float64) (geometry, error) {
	if err := validateCoordinate(longitude, latitude); err != nil {
		return geometry{}, err
	}
	return geometry{Type: "Point", Coordinates: []float64{longitude, latitude}}, nil
}

func timedMultiLineGeometry(points []pathSample) (geometry, [][]string, error) {
	segments, err := splitTimedLine(points)
	if err != nil {
		return geometry{}, nil, err
	}
	coordinates := make([][][]float64, 0, len(segments))
	times := make([][]string, 0, len(segments))
	for _, segment := range segments {
		line := make([][]float64, len(segment))
		lineTimes := make([]string, len(segment))
		for index, point := range segment {
			line[index] = []float64{point.Longitude, point.Latitude}
			lineTimes[index] = formatTime(point.Time)
		}
		coordinates = append(coordinates, line)
		times = append(times, lineTimes)
	}
	return geometry{Type: "MultiLineString", Coordinates: coordinates}, times, nil
}

func geoMultiLineGeometry(points []geodata.GeoPoint, closeLine bool) (geometry, error) {
	if len(points) < 2 {
		return geometry{}, fmt.Errorf("geojson: line requires at least two points")
	}
	for _, point := range points {
		if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
			return geometry{}, err
		}
	}
	geographic := append([]geodata.GeoPoint(nil), points...)
	if closeLine && !geodata.SameGeoPoint(geographic[0], geographic[len(geographic)-1]) {
		geographic = append(geographic, geographic[0])
	}
	segments := geodata.PolylineSegments(geographic, geodata.ProjectionEquirectangular)
	coordinates := make([][][]float64, 0, len(segments))
	for _, segment := range segments {
		if len(segment) < 2 {
			continue
		}
		line := make([][]float64, len(segment))
		for index, point := range segment {
			line[index] = []float64{point.Longitude, point.Latitude}
		}
		coordinates = append(coordinates, line)
	}
	if len(coordinates) == 0 {
		return geometry{}, fmt.Errorf("geojson: line has no valid segments")
	}
	return geometry{Type: "MultiLineString", Coordinates: coordinates}, nil
}

func multiPolygonGeometry(polygons [][]geodata.GeoPoint) (geometry, error) {
	fragments := make([][]geodata.GeoPoint, 0, len(polygons))
	for index, polygon := range polygons {
		polygon = openRing(polygon)
		if len(polygon) < 3 {
			return geometry{}, fmt.Errorf("geojson: polygon %d requires at least three points", index)
		}
		for _, point := range polygon {
			if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
				return geometry{}, err
			}
		}
		fragments = append(fragments,
			geodata.PolygonFragments(polygon, geodata.ProjectionEquirectangular)...)
	}
	return multiPolygonGeometryFromFragments(fragments)
}

func multiPolygonGeometryFromFragments(fragments [][]geodata.GeoPoint) (geometry, error) {
	coordinates := make([][][][]float64, 0, len(fragments))
	for _, fragment := range fragments {
		ring, err := geoJSONRing(fragment)
		if err != nil {
			return geometry{}, err
		}
		coordinates = append(coordinates, [][][]float64{ring})
	}
	if len(coordinates) == 0 {
		return geometry{}, fmt.Errorf("geojson: polygon has no valid rings")
	}
	return geometry{Type: "MultiPolygon", Coordinates: coordinates}, nil
}

func geoJSONRing(points []geodata.GeoPoint) ([][]float64, error) {
	points = openRing(points)
	if len(points) < 3 {
		return nil, fmt.Errorf("geojson: polygon ring requires at least three points")
	}
	for _, point := range points {
		if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
			return nil, err
		}
	}
	area := polygonArea(points)
	if math.Abs(area) < 1e-12 {
		return nil, fmt.Errorf("geojson: polygon ring has zero area")
	}
	if area < 0 {
		points = append([]geodata.GeoPoint(nil), points...)
		for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 {
			points[left], points[right] = points[right], points[left]
		}
	}
	ring := make([][]float64, 0, len(points)+1)
	for _, point := range points {
		ring = append(ring, []float64{point.Longitude, point.Latitude})
	}
	return append(ring, []float64{points[0].Longitude, points[0].Latitude}), nil
}

func openRing(points []geodata.GeoPoint) []geodata.GeoPoint {
	if len(points) > 1 && geodata.SameGeoPoint(points[0], points[len(points)-1]) {
		return points[:len(points)-1]
	}
	return points
}

func polygonArea(points []geodata.GeoPoint) float64 {
	area := 0.0
	for index, point := range points {
		next := points[(index+1)%len(points)]
		area += point.Longitude*next.Latitude - next.Longitude*point.Latitude
	}
	return area / 2
}

func splitTimedLine(points []pathSample) ([][]pathSample, error) {
	if len(points) < 2 {
		return nil, fmt.Errorf("geojson: line requires at least two points")
	}
	for index, point := range points {
		if point.Time.IsZero() {
			return nil, fmt.Errorf("geojson: timed line contains a zero time")
		}
		if err := validateCoordinate(point.Longitude, point.Latitude); err != nil {
			return nil, err
		}
		if index > 0 && !point.Time.After(points[index-1].Time) {
			return nil, fmt.Errorf("geojson: timed line times must be strictly increasing")
		}
	}

	segments := make([][]pathSample, 0, 2)
	current := []pathSample{points[0]}
	previousUnwrapped := points[0]
	worldShift := 0.0
	for index := 1; index < len(points); index++ {
		b := points[index]
		for b.Longitude-previousUnwrapped.Longitude > 180 {
			b.Longitude -= 360
		}
		for b.Longitude-previousUnwrapped.Longitude < -180 {
			b.Longitude += 360
		}
		localLongitude := b.Longitude - worldShift
		if localLongitude >= -180 && localLongitude <= 180 {
			b.Longitude = localLongitude
			current = append(current, b)
			previousUnwrapped = points[index]
			previousUnwrapped.Longitude = b.Longitude + worldShift
			continue
		}
		boundary := 180.0
		if localLongitude < -180 {
			boundary = -180
		}
		unwrappedBoundary := boundary + worldShift
		fraction := (unwrappedBoundary - previousUnwrapped.Longitude) /
			(b.Longitude - previousUnwrapped.Longitude)
		crossing := interpolatePathSample(previousUnwrapped, b, fraction, boundary)
		if !crossing.Time.Equal(current[len(current)-1].Time) {
			current = append(current, crossing)
		}
		if len(current) >= 2 {
			segments = append(segments, current)
		}
		crossing.Longitude = -boundary
		if boundary > 0 {
			worldShift += 360
		} else {
			worldShift -= 360
		}
		b.Longitude -= worldShift
		current = []pathSample{crossing, b}
		previousUnwrapped = points[index]
		previousUnwrapped.Longitude = b.Longitude + worldShift
	}
	if len(current) >= 2 {
		segments = append(segments, current)
	}
	if len(segments) == 0 {
		return nil, fmt.Errorf("geojson: line has no valid segments")
	}
	return segments, nil
}

func interpolatePathSample(a, b pathSample, fraction, longitude float64) pathSample {
	duration := b.Time.Sub(a.Time)
	return pathSample{
		Time:      a.Time.Add(time.Duration(float64(duration) * fraction)),
		Longitude: longitude,
		Latitude:  a.Latitude + (b.Latitude-a.Latitude)*fraction,
	}
}

func validateCoordinate(longitude, latitude float64) error {
	if math.IsNaN(longitude) || math.IsInf(longitude, 0) || longitude < -180 || longitude > 180 {
		return fmt.Errorf("geojson: longitude must be finite and within [-180, 180]")
	}
	if math.IsNaN(latitude) || math.IsInf(latitude, 0) || latitude < -90 || latitude > 90 {
		return fmt.Errorf("geojson: latitude must be finite and within [-90, 90]")
	}
	return nil
}

func finiteGeoJSON(value float64) bool {
	return !math.IsNaN(value) && !math.IsInf(value, 0)
}

func formatTime(value time.Time) string {
	if value.IsZero() {
		return ""
	}
	return value.UTC().Format(time.RFC3339Nano)
}

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