package main

// The data sources band-opening detection depends on, and the settings that
// switch them on.
//
// The detection shipped reading whatever the operator's cluster nodes happened
// to carry. That was a design mistake of the worst kind: the feature depended
// on RBN feeds and a PSK Reporter subscription that nobody could know were
// needed, so it looked broken rather than unconfigured. Nexus showed a 6 m
// opening with 869 stations while OpsLog, on the same PC, showed nothing.
//
// So enabling the watch ARRANGES ITS OWN SOURCES: it adds the two RBN nodes if
// they are missing and brings the PSK Reporter feed up. Turning it off leaves
// the nodes alone — they may have been wanted for their own sake, and silently
// removing a cluster node an operator is using would be worse than leaving one
// they no longer need.

import (
	"strconv"
	"strings"

	"hamlog/internal/applog"
	"hamlog/internal/bandopen"
	"hamlog/internal/cluster"
	"hamlog/internal/geo"
	"hamlog/internal/gridcache"
	"hamlog/internal/pskr"
)

const (
	keyBandOpenEnabled = "bandopen.enabled"
	keyBandOpenBands   = "bandopen.bands"  // comma-separated; empty = the default set
	keyBandOpenNearKm  = "bandopen.nearkm" // receiver radius; empty = pskr.DefaultNearKm
)

// rbnNodes are the two Reverse Beacon Network endpoints the watch wants: CW and
// digital are separate ports and carry different skimmers.
var rbnNodes = []cluster.ServerConfig{
	{Name: "RBN CW", Host: "telnet.reversebeacon.net", Port: 7000, Enabled: true},
	{Name: "RBN FTx", Host: "telnet.reversebeacon.net", Port: 7001, Enabled: true},
}

// BandOpenSettings is the panel's shape.
type BandOpenSettings struct {
	Enabled bool     `json:"enabled"`
	Bands   []string `json:"bands"`
	// Available is every band that can be watched, so the UI does not carry its
	// own copy of a list that belongs to the detector.
	Available []string `json:"available"`
	// NearKm is how close a RECEIVER has to be for its report to count as
	// evidence about the operator's own path. An opening on these bands reaches
	// an AREA, and the right radius is a judgement about how big that area is —
	// a 2 m tropo duct is not a 10 m Es footprint — so it is the operator's to
	// make rather than a constant.
	//
	// Tightening it costs nothing in traffic: the broker subscription is one
	// ring of squares whatever this says, and this only decides what survives
	// once the messages are here. It costs SENSITIVITY — too small a radius in a
	// thinly-populated region leaves no receivers at all, and a watch with
	// nothing to look at reports nothing while appearing to work.
	NearKm int `json:"near_km"`
}

func (a *App) GetBandOpenSettings() BandOpenSettings {
	s := BandOpenSettings{
		Enabled:   a.settingOr(keyBandOpenEnabled, "") == "1",
		Bands:     splitCSV(a.settingOr(keyBandOpenBands, "")),
		Available: pskr.Bands,
		NearKm:    bandOpenNearKm(a.settingOr(keyBandOpenNearKm, "")),
	}
	// Keep only bands that are still offered. A saved selection outlives the code
	// that made it: 12 m was dropped from the watched set, but every operator who
	// had already enabled the watch kept subscribing to it — paying for a firehose
	// whose messages the detector then threw away.
	s.Bands = keepKnownBands(s.Bands)
	if len(s.Bands) == 0 {
		s.Bands = append(s.Bands, pskr.Bands...)
	}
	return s
}

func keepKnownBands(want []string) []string {
	ok := make(map[string]bool, len(pskr.Bands))
	for _, b := range pskr.Bands {
		ok[b] = true
	}
	out := make([]string, 0, len(want))
	for _, b := range want {
		if ok[strings.ToLower(strings.TrimSpace(b))] {
			out = append(out, b)
		}
	}
	return out
}

// pskrMaxGrids bounds the receiver squares the broker is asked to filter on.
// Each is one subscription, and the traffic follows the area: 2000 km is about
// 615 squares, which the broker takes in batches without complaint. The cap is
// there so an absurd radius cannot turn into thousands of subscriptions — past
// it the nearest squares are kept, and the log says how many.
const pskrMaxGrids = 900

// bandOpenNearKm reads the stored receiver radius, falling back to the default
// for anything unset or out of range. The bounds are the two ways to make the
// watch useless: below 25 km almost nobody is ever near enough to hear anything,
// and past 3000 km a "nearby" receiver is on the far side of a continent, which
// says nothing about the operator's own path.
//
// The ceiling was 1000 km, chosen with Europe in mind, where that radius holds a
// dozen countries' worth of receivers. It is the wrong number in VK: a station
// there can have almost nobody inside 300 km, and the chase list stayed empty
// not because nothing was on the air but because nothing was listening close
// enough to count.
func bandOpenNearKm(raw string) int {
	n, err := strconv.Atoi(strings.TrimSpace(raw))
	if err != nil || n < 25 || n > 3000 {
		return pskr.DefaultNearKm
	}
	return n
}

func (a *App) SaveBandOpenSettings(s BandOpenSettings) error {
	a.setSetting(keyBandOpenEnabled, map[bool]string{true: "1", false: "0"}[s.Enabled])
	a.setSetting(keyBandOpenBands, strings.Join(s.Bands, ","))
	a.setSetting(keyBandOpenNearKm, strconv.Itoa(bandOpenNearKm(strconv.Itoa(s.NearKm))))
	if s.Enabled {
		a.ensureRBNNodes()
	}
	a.startBandOpenFeed()
	return nil
}

// ensureRBNNodes adds the RBN endpoints when they are absent.
//
// Matched on host AND port rather than on name: an operator who renamed theirs
// "Skimmers CW" has the node, and adding a second one pointed at the same
// server would give them every spot twice — which the detector would read as
// twice as many stations, i.e. an opening that is not there.
func (a *App) ensureRBNNodes() {
	have, err := a.ListClusterServers()
	if err != nil {
		applog.Printf("bandopen: cannot read the cluster nodes (%v) — not adding RBN", err)
		return
	}
	for _, want := range rbnNodes {
		found := false
		for _, h := range have {
			if strings.EqualFold(strings.TrimSpace(h.Host), want.Host) && h.Port == want.Port {
				found = true
				break
			}
		}
		if found {
			continue
		}
		if _, err := a.SaveClusterServer(want); err != nil {
			applog.Printf("bandopen: could not add %s: %v", want.Name, err)
			continue
		}
		applog.Printf("bandopen: added cluster node %s (%s:%d) — the watch needs it",
			want.Name, want.Host, want.Port)
	}
}

// startBandOpenFeed brings the PSK Reporter subscription up or down to match
// the setting. Called at startup and whenever the setting is saved.
func (a *App) startBandOpenFeed() {
	if a.pskr != nil {
		a.pskr.Stop()
		a.pskr = nil
	}
	s := a.GetBandOpenSettings()
	a.bandOpen.on.Store(s.Enabled)
	a.setBandOpenBands(s.Bands)
	if !s.Enabled {
		// Put out whatever is currently lit. Leaving the badges up would keep
		// announcing an opening from a watch that is now off, and they only fade
		// on a timer fed by spots this path no longer looks at — so they would
		// hang there until the app restarted.
		a.clearBandOpenings()
	}
	chaseGrids := a.gridStore != nil
	chaseNew := a.chaseNewEnabled()
	// Three consumers, one feed. Any one of them is reason enough to bring it up,
	// and turning one off must not cut the others loose.
	if !s.Enabled && !chaseGrids && !chaseNew {
		return
	}
	// Every spot is measured from the operator's position. Without one there is
	// nothing to measure, and a detector fed unmeasurable spots reports nothing
	// while looking like it is working.
	if !a.opSet {
		applog.Printf("pskr: no station grid set — the feed needs one to measure a path")
		return
	}

	// Grid chasing and new-chasing want every band; the opening watch wants its
	// four. "+" is the MQTT single-level wildcard, so one subscription per square
	// covers the lot.
	bands := s.Bands
	if chaseGrids || chaseNew {
		bands = []string{"+"}
	}
	// Filter at the BROKER on the receiver's square rather than receiving the
	// world and discarding it here. Measured on the live feed: the four opening
	// bands unfiltered are 83 messages a second, of which roughly one in a
	// hundred survived the NearKm test below. One ring of squares — about the
	// same 300 km — is 0.2 to 1.2 a second, and the same for every operator,
	// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
	//
	// The set follows the radius the operator asked for. It was one fixed ring
	// whatever they set — about 300 km — so raising the radius bought nothing:
	// the reports that would have satisfied it were never sent to us in the
	// first place, and an operator in a thinly-populated region who widened the
	// circle to find some receivers saw no change at all.
	rxGrids := geo.GridsWithin(a.opLat, a.opLon, float64(s.NearKm), pskrMaxGrids)
	if len(rxGrids) == 0 {
		rxGrids = geo.NeighbourGrids(a.opLat, a.opLon, 1)
	}
	applog.Printf("pskr: receiver filter — %d squares within %d km of the station", len(rxGrids), s.NearKm)

	var onGrid func(call, grid string)
	if chaseGrids {
		onGrid = func(call, grid string) { a.rememberDecodeGrid(call, grid, gridcache.SourceMQTT) }
	}
	var onSpot func(pskr.Spot)
	switch {
	case s.Enabled && chaseNew:
		onSpot = func(sp pskr.Spot) { a.feedBandOpen(sp); a.feedChaseNew(sp) }
	case s.Enabled:
		onSpot = a.feedBandOpen
	case chaseNew:
		onSpot = a.feedChaseNew
	}

	a.pskr = pskr.New(pskr.Config{
		Bands:   bands,
		RxGrids: rxGrids,
		NearKm:  s.NearKm,
		OpLat:   a.opLat, OpLon: a.opLon,
		Geo: func(grid string) (int, int, bool) {
			lat, lon, ok := gridToLatLon(grid)
			if !ok {
				return 0, 0, false
			}
			// The same arithmetic the cluster path uses, so one spot cannot be
			// 2000 km away down one road and 2100 km down the other.
			d := int(haversineKm(a.opLat, a.opLon, lat, lon) + 0.5)
			b := int(initialBearingDeg(a.opLat, a.opLon, lat, lon) + 0.5)
			return d, b, true
		},
		OnSpot: onSpot,
		OnGrid: onGrid,
		Logf:   applog.Printf,
	})
	if err := a.pskr.Start(); err != nil {
		applog.Printf("pskr: feed did not start: %v", err)
		return
	}
	applog.Printf("pskr: feed up — bands %v, %d receiver squares (openings=%v, grids=%v, chase-new=%v)",
		bands, len(rxGrids), s.Enabled, chaseGrids, chaseNew)
}

// feedBandOpen hands one PSK Reporter decode to the detector.
//
// Called from the MQTT goroutine at up to thousands a minute when 6 m is open,
// so it does the least possible: the detector's own window and de-duplication
// by callsign are what turn that flood into one announcement.
func (a *App) feedBandOpen(s pskr.Spot) {
	if !a.bandOpenWanted(s.Band) {
		return
	}
	a.bandOpen.mu.Lock()
	if a.bandOpen.det == nil {
		a.bandOpen.det = bandopen.New(bandopen.DefaultConfig())
	}
	op := a.bandOpen.det.Add(bandopen.Spot{
		Call: s.Call, Band: s.Band, DistKm: s.DistKm, Bearing: s.Bearing, At: s.At,
	}, a.opLat)
	if op != nil {
		a.rememberOpening(*op)
	}
	if s.DistKm >= bandopen.DefaultConfig().MinKm {
		a.markBandAlive(s.Band, s.At)
	}
	a.bandOpen.mu.Unlock()
	if op != nil {
		a.announceOpening(*op)
	}
}

// GetPSKReporterStatus is what the settings panel polls.
func (a *App) GetPSKReporterStatus() pskr.Status {
	if a.pskr == nil {
		return pskr.Status{Bands: pskr.Bands}
	}
	return a.pskr.Status()
}

// settingOr reads one key, falling back when the store is not up yet or the
// value is blank. The settings store is a plain string key/value and every
// caller does this by hand; two of them here earn the helper.
func (a *App) settingOr(key, def string) string {
	if a.settings == nil {
		return def
	}
	v, _ := a.settings.Get(a.ctx, key)
	if strings.TrimSpace(v) == "" {
		return def
	}
	return v
}

func splitCSV(s string) []string {
	var out []string
	for _, p := range strings.Split(s, ",") {
		if p = strings.TrimSpace(p); p != "" {
			out = append(out, p)
		}
	}
	return out
}
