package main

// Chase New — a widget listing the stations PSK Reporter is hearing NEAR HERE
// that are new against the log.
//
// The feed is the one the band-opening watch and the grid store already use, so
// this costs no extra subscription when either is on: it reads messages that
// were arriving and being discarded. Measured on the live broker, one ring of
// neighbour squares is 0.2 to 1.2 messages a second.
//
// Two things about the data decide the shape of everything below:
//
//   - PSK Reporter is DIGITAL ONLY. This can never show a new entity on CW or
//     SSB, and the panel says so rather than letting an operator conclude the
//     band is dead when it is full of CW.
//   - A report says "X was heard BY Y". The watcher already drops anything
//     collected further than NearKm from the operator (internal/pskr), so what
//     arrives here is a station being heard in this region — not a world map.
//
// "New" is NOT decided here. It goes through ClusterSpotStatuses, the same
// function the DX cluster grid uses, because two definitions of new is how the
// two panels quietly start disagreeing about the same callsign.

import (
	"sort"
	"strings"
	"sync"
	"time"

	"hamlog/internal/applog"
	"hamlog/internal/pskr"
)

// keyChaseNew turns the widget on. Deliberately NOT nested under "chase grids":
// chasing squares and chasing entities are different wants, and an operator may
// have one without the other. They only share the feed, which either one starts.
const keyChaseNew = "cluster.chase_new"

// keyChaseNewBandsOff lists the bands the panel is NOT to show, comma
// separated.
//
// Stored as the EXCLUSIONS rather than the selection, so that an empty setting
// — every operator who has never opened this — means "show them all", and so
// that a band added to the station's list later appears here on its own. Stored
// the other way round, a selection saved today would silently hide 4 m the day
// a transverter arrives.
const keyChaseNewBandsOff = "cluster.chase_new_bands_off"

// ChaseNewBands is the band vocabulary of the panel's own filter: HF through
// 70 cm, in the order an operator reads a band plan.
//
// A fixed list, not the station's: the two answer different questions. The
// station list says what this station can work at all, and still applies —
// this one says what is worth WATCHING right now, which changes with the hour
// and the season, not with the shack.
var ChaseNewBands = []string{
	"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
	"6m", "4m", "2m", "70cm",
}

// chaseNewMax bounds the panel. A list nobody can read to the bottom is not more
// information, and the oldest rows are the least likely to still be on the air.
const chaseNewMax = 200

// chaseSeenTTL is how long the same station stays de-duplicated on one band and
// mode. PSK Reporter re-reports a calling station every cycle — without this the
// panel would be one operator repeated fifty times.
const chaseSeenTTL = 10 * time.Minute

// ChaseNewSpot is one station worth looking at, as the widget shows it.
type ChaseNewSpot struct {
	Call    string `json:"call"`
	Band    string `json:"band"`
	Mode    string `json:"mode"`
	FreqHz  int64  `json:"freq_hz"`
	Grid    string `json:"grid"`
	Country string `json:"country,omitempty"`
	Cont    string `json:"cont,omitempty"`
	DistKm  int    `json:"dist_km"`
	Bearing int    `json:"bearing"`
	// Status is the entity-level verdict from the cluster's own vocabulary:
	// new | new-band | new-mode | new-slot. Empty when the row is here for a
	// prefix or a square instead.
	Status  string `json:"status,omitempty"`
	NewPfx  bool   `json:"new_pfx,omitempty"`
	NewGrid bool   `json:"new_grid,omitempty"`
	LoTW    bool   `json:"lotw,omitempty"`
	At      string `json:"at"` // RFC3339, stamped on receipt
}

// chaseNewStore holds what the widget shows. Written from the MQTT goroutine,
// read by the UI poll, so everything is behind one mutex — the work per message
// is a handful of map lookups and this must never become the reason the broker's
// buffer backs up.
type chaseNewStore struct {
	mu    sync.Mutex
	spots []ChaseNewSpot       // newest last
	seen  map[string]time.Time // "CALL|BAND|MODE" → when it was last shown
}

func newChaseNewStore() *chaseNewStore {
	return &chaseNewStore{seen: make(map[string]time.Time, 512)}
}

// put adds a spot unless the same station on the same band and mode is already
// on the list. Returns false when it was a duplicate.
func (s *chaseNewStore) put(sp ChaseNewSpot, now time.Time) bool {
	key := sp.Call + "|" + sp.Band + "|" + sp.Mode
	s.mu.Lock()
	defer s.mu.Unlock()
	if last, ok := s.seen[key]; ok && now.Sub(last) < chaseSeenTTL {
		return false
	}
	s.seen[key] = now
	s.spots = append(s.spots, sp)
	if len(s.spots) > chaseNewMax {
		s.spots = s.spots[len(s.spots)-chaseNewMax:]
	}
	// The de-duplication map is the only thing here that grows without a natural
	// bound, so it is swept when it gets large rather than on every message.
	if len(s.seen) > 4*chaseNewMax {
		for k, t := range s.seen {
			if now.Sub(t) >= chaseSeenTTL {
				delete(s.seen, k)
			}
		}
	}
	return true
}

// list returns the spots newer than ttl, newest first.
func (s *chaseNewStore) list(ttl time.Duration, now time.Time) []ChaseNewSpot {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]ChaseNewSpot, 0, len(s.spots))
	for _, sp := range s.spots {
		at, err := time.Parse(time.RFC3339, sp.At)
		if err == nil && ttl > 0 && now.Sub(at) > ttl {
			continue
		}
		out = append(out, sp)
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].At > out[j].At })
	return out
}

func (s *chaseNewStore) clear() {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.spots = nil
	s.seen = make(map[string]time.Time, 512)
}

// chaseModes is what the panel will show. PSK Reporter reports everything its
// receivers decode, and most of it is not a contact waiting to happen:
//
//   - WSPR is a beacon. Nobody answers a WSPR transmission, so a "new entity on
//     WSPR" is a path report, not a station to work — and on a quiet band it
//     would be most of the list.
//   - JT65, JT9, FST4W, Q65 and the rest are real but rare enough that they
//     would only dilute what an operator scans.
//
// The list is the modes an operator actually calls on. Deliberately not a
// setting: a widget with its own mode list is a second place to get the answer
// wrong, and this one is short enough to read.
var chaseModes = map[string]bool{
	"FT8": true, "FT4": true, "FT2": true, "PSK31": true, "RTTY": true,
}

// chaseNewEnabled reads the option. Called per message, so it reads the cached
// atomic rather than the settings store.
func (a *App) chaseNewEnabled() bool { return a.chaseNewOn.Load() }

// GetChaseNewBands returns the bands the panel is set to SHOW — every band in
// the vocabulary that has not been switched off.
func (a *App) GetChaseNewBands() []string {
	off := map[string]bool{}
	for _, b := range splitCSV(a.settingOr(keyChaseNewBandsOff, "")) {
		off[strings.ToLower(strings.TrimSpace(b))] = true
	}
	out := make([]string, 0, len(ChaseNewBands))
	for _, b := range ChaseNewBands {
		if !off[b] {
			out = append(out, b)
		}
	}
	return out
}

// SetChaseNewBands takes the bands to SHOW and stores the complement.
//
// Nothing is filtered out of the store retroactively: the rows already
// collected stay until they age out, and the next message is the one that
// obeys. A panel that emptied itself on a settings click would look like it had
// lost the feed.
func (a *App) SetChaseNewBands(show []string) {
	want := map[string]bool{}
	for _, b := range show {
		want[strings.ToLower(strings.TrimSpace(b))] = true
	}
	var off []string
	for _, b := range ChaseNewBands {
		if !want[b] {
			off = append(off, b)
		}
	}
	a.setSetting(keyChaseNewBandsOff, strings.Join(off, ","))
	a.refreshChaseNewBands()
	applog.Printf("chase new: showing %d of %d bands", len(ChaseNewBands)-len(off), len(ChaseNewBands))
}

// refreshChaseNewBands re-reads the panel's own band filter into the cache the
// feed consults. Called at startup, and whenever the selection is saved.
func (a *App) refreshChaseNewBands() {
	off := map[string]bool{}
	for _, b := range splitCSV(a.settingOr(keyChaseNewBandsOff, "")) {
		if b = strings.ToLower(strings.TrimSpace(b)); b != "" {
			off[b] = true
		}
	}
	a.chaseNewBandsOff.Store(off)
}

// chaseNewBandShown is the per-message half: a map read on the MQTT goroutine.
func (a *App) chaseNewBandShown(band string) bool {
	v := a.chaseNewBandsOff.Load()
	if v == nil {
		return true // nothing loaded yet — better to show than to swallow
	}
	off, ok := v.(map[string]bool)
	if !ok {
		return true
	}
	return !off[strings.ToLower(strings.TrimSpace(band))]
}

// chaseBandAllowed reports whether a band is one the operator uses.
//
// PSK Reporter carries every band its receivers listen on, including microwave
// segments nobody in the region is equipped for. A 13 cm decode is not an
// opportunity for a station with no 13 cm — it is a row in the way.
//
// The answer is the operator's own band list (Settings → Modes & bands), read
// once and cached: this is consulted per message, and re-reading a JSON setting
// at that rate on the MQTT goroutine is exactly what must not happen.
func (a *App) chaseBandAllowed(band string) bool {
	a.chaseBandsMu.RLock()
	m := a.chaseBands
	a.chaseBandsMu.RUnlock()
	if m == nil {
		return true // list not loaded yet — better to show than to swallow
	}
	return m[strings.ToLower(strings.TrimSpace(band))]
}

// refreshChaseBands re-reads the operator's band list into the cache. Called
// when the widget is switched on and whenever the lists are saved.
func (a *App) refreshChaseBands() {
	s, _ := a.GetListsSettings()
	m := make(map[string]bool, len(s.Bands))
	for _, b := range s.Bands {
		if b = strings.ToLower(strings.TrimSpace(b)); b != "" {
			m[b] = true
		}
	}
	a.chaseBandsMu.Lock()
	a.chaseBands = m
	a.chaseBandsMu.Unlock()
}

// refreshChaseNew re-reads the option into the atomic the feed consults.
func (a *App) refreshChaseNew() {
	on := a.settingOr(keyChaseNew, "") == "1"
	a.chaseNewOn.Store(on)
	if on {
		// Read the band list here rather than per message. Saving the lists calls
		// back into this, so a band ticked in Settings applies to the next decode.
		a.refreshChaseBands()
	}
	if !on && a.chaseNew != nil {
		// Drop the list rather than leave it on screen: it would go stale with no
		// feed behind it, and a frozen list of "new" stations is worse than none.
		a.chaseNew.clear()
	}
}

// feedChaseNew turns one PSK Reporter decode into a widget row, or drops it.
//
// Runs on the MQTT goroutine. The cheap tests come first — the option, then the
// de-duplication — so a station already listed costs one map lookup and nothing
// else.
func (a *App) feedChaseNew(sp pskr.Spot) {
	if !a.chaseNewEnabled() || a.chaseNew == nil {
		return
	}
	call := strings.ToUpper(strings.TrimSpace(sp.Call))
	if call == "" {
		return
	}
	band := strings.ToLower(strings.TrimSpace(sp.Band))
	mode := strings.ToUpper(strings.TrimSpace(sp.Mode))
	// Both tests before the status lookup: they are map reads on short keys and
	// they throw away most of the feed, so nothing further down pays for a band
	// this station cannot work or a mode nobody answers.
	// Two band tests, and they answer two questions: can this station work the
	// band at all (its own list), and does the operator want to WATCH it right
	// now (this panel's own filter). A station with 2 m equipment that is
	// chasing HF tonight needs the second one.
	if !chaseModes[mode] || !a.chaseBandAllowed(band) || !a.chaseNewBandShown(band) {
		return
	}

	// The same verdict the cluster grid computes, from the same cached index:
	// map lookups per spot, no query.
	st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode}})
	if len(st) == 0 {
		return
	}
	s := st[0]
	isNew := s.Status == "new" || s.Status == "new-band" ||
		s.Status == "new-mode" || s.Status == "new-slot" || s.NewPfx || s.NewGrid
	if !isNew {
		return
	}

	now := time.Now()
	row := ChaseNewSpot{
		Call: call, Band: band, Mode: mode, FreqHz: sp.FreqHz,
		Grid: sp.Grid, Country: s.Country, Cont: s.Continent,
		DistKm: sp.DistKm, Bearing: sp.Bearing,
		Status: s.Status, NewPfx: s.NewPfx, NewGrid: s.NewGrid, LoTW: s.LoTW,
		At: now.UTC().Format(time.RFC3339),
	}
	// The grid the watcher gives is the transmitter's own, straight off the air —
	// better than anything we could look up, so it is kept even when the status
	// index had one.
	if row.Grid == "" {
		row.Grid = s.Grid
	}
	a.chaseNew.put(row, now)
}

// GetChaseNewSpots returns what the widget should show, newest first, aged out
// with the same spot lifetime the cluster and band maps use — one setting for
// "how long is a spot worth looking at", not three.
func (a *App) GetChaseNewSpots() []ChaseNewSpot {
	if a.chaseNew == nil || !a.chaseNewEnabled() {
		return []ChaseNewSpot{}
	}
	ttl := time.Duration(a.GetSpotTTLMinutes()) * time.Minute
	return a.chaseNew.list(ttl, time.Now())
}

// GetChaseNew reports whether the widget is on.
//
// Reads the SETTING, not the cached atomic. The atomic exists for the MQTT
// goroutine and is false until startup has read the option — so a UI that asked
// this while the database was still opening was told "off", believed it, and
// never asked again. The toolbar button only appeared after opening Settings
// and closing it, which is what re-read it.
func (a *App) GetChaseNew() bool { return a.settingOr(keyChaseNew, "") == "1" }

// SetChaseNew turns the widget on or off and brings the feed up or down with it.
func (a *App) SetChaseNew(on bool) error {
	v := "0"
	if on {
		v = "1"
	}
	a.setSetting(keyChaseNew, v)
	a.refreshChaseNew()
	// The feed is shared: startBandOpenSources decides whether it is still needed
	// by anything else, so turning this off does not cut the grid store loose.
	a.startBandOpenFeed()
	applog.Printf("chase new: %v", on)
	return nil
}
