// Package bandopen spots a band OPENING in the cluster stream — sporadic-E on
// 6 m, 4 m and 2 m above all.
//
// This is observation, not prediction. Every spot OpsLog receives is already
// enriched with the great-circle distance and bearing from the operator's own
// grid, so the signature of a single-hop Es opening is directly measurable:
// several distinct stations appearing on a VHF band, all at single-hop range,
// all in the same bearing sector, within a few minutes. That combination does
// not happen by chance — scattered spots at random distances and bearings are
// just a busy band.
//
// The season is REPORTED, never used to suppress. Es peaks in late spring and
// summer, so an opening in November is unusual — and an unusual opening is
// precisely the one an operator must not be told about last. InSeason only
// labels the announcement.
package bandopen

import (
	"math"
	"sort"
	"strconv"
	"strings"
	"time"
)

// Spot is the little a detection needs, taken from an enriched cluster spot.
type Spot struct {
	Call    string
	Band    string
	DistKm  int
	Bearing int // degrees from the operator, short path
	At      time.Time
}

// Config tunes the detector. The defaults describe single-hop sporadic E.
type Config struct {
	Window        time.Duration // how far back a burst may span
	MinCalls      int           // distinct DX calls before it counts as an opening
	MinKm, MaxKm  int           // path length accepted; MaxKm 0 = no ceiling
	BearingSpread int           // widest arc (degrees) the spots may cover
	Requiet       time.Duration // silence after announcing a band, so it is announced once
}

// DefaultConfig is the Es envelope.
//
// Below ~500 km a 6 m contact is ordinary tropo or ground wave and says nothing
// about the ionosphere, so that floor stays.
//
// There is NO ceiling. There used to be one at 2400 km, on the reasoning that
// past a single hop the bearing test stops meaning anything. That was wrong, and
// it silently threw away exactly the openings worth hearing about: multi-hop Es
// is ordinary on 6 m, 5000 km paths are common and 10000 km happens. Those are
// still directional — a double hop leaves the same sector it entered — so the
// bearing test holds perfectly well, and it is the test doing the real work here.
//
// 90° of spread because a genuine Es cloud illuminates a sector, not the whole
// horizon: the constraint that separates an opening from a merely busy evening.
func DefaultConfig() Config {
	return Config{
		Window:        12 * time.Minute,
		MinCalls:      4,
		MinKm:         500,
		MaxKm:         0, // no ceiling — see above
		BearingSpread: 90,
		Requiet:       45 * time.Minute,
	}
}

// Bands watched.
//
// 10 m is in, and it is HF. The line is not "HF versus VHF" but "is an opening
// here an event": 20 m being open is the normal state of the band and saying so
// is noise, while 10 m spends most of a solar cycle shut and opens sharply when
// it goes — which is what an operator wants interrupting them for.
//
// 12 m was tried and dropped. It behaves too much like 20 m at this point in the
// cycle: open often enough that announcing it is a notification rather than
// news, and every band that cries wolf costs the ones that do not.
var watched = map[string]bool{"10m": true, "6m": true, "4m": true, "2m": true}

// Watched reports whether a band is one the detector looks at.
func Watched(band string) bool { return watched[strings.ToLower(strings.TrimSpace(band))] }

// maxTerrestrialKm is the longest path a band can carry through the atmosphere.
// Zero means no limit.
//
// The flat 2400 km ceiling was removed because it threw away real multi-hop Es
// on 6 m, and that was right — but "no limit anywhere" then let something else
// through. A 2 m opening was announced at 9650 km towards Japan, on stations
// that were unmistakably working EME: the moon is not an opening, and pointing
// an antenna at that bearing would find nothing.
//
// So the limit is per band, and it is physics rather than a threshold. Two
// metres reaches a few thousand kilometres by tropospheric duct or a chain of Es
// clouds and no further; beyond that the path went via the moon or a satellite,
// neither of which says anything about the band. Six and ten metres have no
// ceiling at all — multi-hop Es and F2 genuinely go round the world.
var maxTerrestrialKm = map[string]int{
	"2m": 3500,
	"4m": 4000,
}

// MaxKmFor returns the plausibility ceiling for a band, 0 for none.
func MaxKmFor(band string) int { return maxTerrestrialKm[strings.ToLower(strings.TrimSpace(band))] }

// Opening is a detected opening, ready to be announced.
type Opening struct {
	Band       string `json:"band"`
	Calls      int    `json:"calls"`       // distinct DX stations seen
	MedianKm   int    `json:"median_km"`   // typical hop length
	BearingMin int    `json:"bearing_min"` // sector, degrees
	BearingMax int    `json:"bearing_max"`
	// Compass is the sector as a point of the compass ("NE"), computed here so
	// the UI never has to redo the wrap-round-north arithmetic that Sector()
	// already gets right — two answers to that question is how a badge ends up
	// naming the opposite direction.
	Compass  string    `json:"compass"`
	InSeason bool      `json:"in_season"` // false = unusual for the time of year
	At       time.Time `json:"at"`
	Examples []string  `json:"examples"` // a few callsigns, for the announcement
}

// Detector keeps the rolling window and the per-band quiet period.
type Detector struct {
	cfg      Config
	recent   []Spot
	lastFire map[string]time.Time
}

func New(cfg Config) *Detector {
	if cfg.Window <= 0 {
		cfg = DefaultConfig()
	}
	return &Detector{cfg: cfg, lastFire: map[string]time.Time{}}
}

// Add records a spot and returns an Opening when this spot completes one.
//
// Returns nil far more often than not; that is the point. lat is the operator's
// latitude, for the hemisphere the season depends on.
func (d *Detector) Add(s Spot, lat float64) *Opening {
	if !Watched(s.Band) {
		return nil
	}
	band := strings.ToLower(strings.TrimSpace(s.Band))
	s.Band = band
	if s.At.IsZero() {
		s.At = time.Now()
	}
	// Out-of-range spots are dropped rather than stored: they can never be part
	// of a single-hop detection, and keeping them only grows the window.
	if s.DistKm < d.cfg.MinKm || (d.cfg.MaxKm > 0 && s.DistKm > d.cfg.MaxKm) {
		return nil
	}
	// Past what the atmosphere can carry on this band, the path went via the moon
	// or a satellite. Those are real contacts and real reports; they are simply
	// not evidence about the band.
	if m := MaxKmFor(band); m > 0 && s.DistKm > m {
		return nil
	}
	d.recent = append(d.recent, s)
	d.prune(s.At)

	if last, ok := d.lastFire[band]; ok && s.At.Sub(last) < d.cfg.Requiet {
		return nil // already announced this band recently
	}

	inBand := make([]Spot, 0, len(d.recent))
	for _, r := range d.recent {
		if r.Band == band {
			inBand = append(inBand, r)
		}
	}
	op := evaluate(band, inBand, d.cfg)
	if op == nil {
		return nil
	}
	op.At = s.At
	op.InSeason = InSeason(band, s.At, lat)
	d.lastFire[band] = s.At
	return op
}

func (d *Detector) prune(now time.Time) {
	cut := now.Add(-d.cfg.Window)
	keep := d.recent[:0]
	for _, r := range d.recent {
		if r.At.After(cut) {
			keep = append(keep, r)
		}
	}
	d.recent = keep
}

// evaluate decides whether a band's recent spots look like one opening.
func evaluate(band string, spots []Spot, cfg Config) *Opening {
	// Distinct callsigns, not spot count: one station spotted by six skimmers is
	// six spots and one station, and it is not an opening.
	seen := map[string]Spot{}
	for _, s := range spots {
		c := strings.ToUpper(strings.TrimSpace(s.Call))
		if c == "" {
			continue
		}
		if _, dup := seen[c]; !dup {
			seen[c] = s
		}
	}
	if len(seen) < cfg.MinCalls {
		return nil
	}
	bearings := make([]int, 0, len(seen))
	dists := make([]int, 0, len(seen))
	calls := make([]string, 0, len(seen))
	for c, s := range seen {
		bearings = append(bearings, ((s.Bearing%360)+360)%360)
		dists = append(dists, s.DistKm)
		calls = append(calls, c)
	}
	// The DENSEST sector, not the sector covering everything.
	//
	// This used to demand that EVERY station fit inside one 90° arc, which is the
	// right shape for a sporadic-E cloud and hopeless for anything else. With 10
	// and 12 m open on F2 the reports come from all round the compass, the arc is
	// 360°, and the test can never pass — so the busier the band, the less likely
	// an opening was announced. Backwards.
	//
	// Finding the busiest 90° instead keeps the Es signature intact — a cloud
	// still makes one direction dense — and lets an F2 opening toward South
	// America be seen through the handful of Europeans that are always there.
	lo, hi, n := densestSector(bearings, cfg.BearingSpread)
	if n < cfg.MinCalls {
		return nil // nothing concentrated anywhere: a busy band, not an opening
	}
	sort.Ints(dists)
	sort.Strings(calls)
	if len(calls) > 5 {
		calls = calls[:5]
	}
	op := &Opening{
		Band: band, Calls: len(seen), MedianKm: dists[len(dists)/2],
		BearingMin: lo, BearingMax: hi, Examples: calls,
	}
	op.Compass = compass(midBearing(lo, hi))
	return op
}

// arc returns the smallest compass sector containing every bearing, coping with
// the wrap at north: 350° and 10° are 20° apart, not 340°.
// densestSector returns the width-degree arc containing the most bearings, as
// its start, end and count.
//
// Brute force over each bearing as a starting edge. n is at most a few hundred
// distinct callsigns in a twelve-minute window, so n² is nothing, and it runs
// once per accepted spot rather than once per decode.
//
// The arc STARTS on a real bearing rather than sweeping every degree: the
// densest window can always be slid until its leading edge sits on a station, so
// nothing is missed and there are 360 fewer positions to try.
func densestSector(b []int, width int) (lo, hi, count int) {
	if len(b) == 0 {
		return 0, 0, 0
	}
	s := append([]int(nil), b...)
	sort.Ints(s)
	best, bestAt := 0, 0
	for i, start := range s {
		n := 0
		for _, x := range s {
			// Distance clockwise from start to x, so the wrap through 0° needs no
			// special case — which is where the old arc() logic earned its keep and
			// this one has to match it.
			d := x - start
			if d < 0 {
				d += 360
			}
			if d <= width {
				n++
			}
		}
		if n > best {
			best, bestAt = n, i
		}
	}
	lo = s[bestAt]
	// The end is the furthest station actually inside the window, not lo+width:
	// reporting an empty 90° when every station sits in the first 20° would
	// overstate the opening's width by four times.
	hi = lo
	for _, x := range s {
		d := x - lo
		if d < 0 {
			d += 360
		}
		if d <= width {
			if e := (lo + d) % 360; d >= ((hi-lo)+360)%360 {
				hi = e
			}
		}
	}
	return lo, hi, best
}

func arc(b []int) (lo, hi, spread int) {
	if len(b) == 0 {
		return 0, 0, 0
	}
	s := append([]int(nil), b...)
	sort.Ints(s)
	// The widest GAP between consecutive bearings (round the circle) is the part
	// NOT covered; the sector is everything else.
	worst, at := -1, 0
	for i := range s {
		next := s[(i+1)%len(s)]
		gap := next - s[i]
		if i == len(s)-1 {
			gap = next + 360 - s[i]
		}
		if gap > worst {
			worst, at = gap, i
		}
	}
	lo = s[(at+1)%len(s)]
	hi = s[at]
	spread = 360 - worst
	return lo, hi, spread
}

// InSeason reports whether the time of year is one where sporadic E is common
// at the operator's latitude.
//
// Each hemisphere has a strong summer peak AND a smaller winter one, and both
// count as expected: a December opening in Europe surprises nobody. What the
// label marks is the genuinely odd month — an equinox opening.
//
// This LABELS a detection, it never gates one. Out-of-season Es exists, and it
// is precisely the opening an operator must not be told about last.
func InSeason(band string, t time.Time, lat float64) bool {
	m := t.UTC().Month()
	var months map[time.Month]bool
	if lat >= 0 {
		months = map[time.Month]bool{
			time.May: true, time.June: true, time.July: true, time.August: true, // main
			time.December: true, time.January: true, // lesser winter peak
		}
	} else {
		months = map[time.Month]bool{
			time.November: true, time.December: true, time.January: true, time.February: true,
			time.June: true, time.July: true,
		}
	}
	return months[m]
}

// Sector renders the bearing range for a human, e.g. "NE (35–75°)".
func (o *Opening) Sector() string {
	return compass(midBearing(o.BearingMin, o.BearingMax)) +
		" (" + strconv.Itoa(o.BearingMin) + "–" + strconv.Itoa(o.BearingMax) + "°)"
}

// midBearing is the middle of the arc running CLOCKWISE from min to max.
//
// Not the arithmetic mean, which is wrong for every sector crossing north and
// wrong by the worst possible amount: an opening reported as 353–61° averaged to
// 207° and was announced as SW when it was NE — the exact opposite direction, to
// an operator who might turn a beam on it. The detector itself has handled the
// 0/360 wrap since it was written; only this label did not.
func midBearing(min, max int) float64 {
	span := max - min
	if span < 0 {
		span += 360
	}
	return math.Mod(float64(min)+float64(span)/2, 360)
}

func compass(deg float64) string {
	names := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"}
	i := int(math.Round(deg/45)) % 8
	if i < 0 {
		i += 8
	}
	return names[i]
}
