package main

// Band-opening announcements — the app-side glue for internal/bandopen.
//
// The detector needs nothing OpsLog does not already compute: the cluster event
// worker enriches every spot with the great-circle distance and bearing from
// the operator's grid before this is called. So watching for sporadic E costs
// one function call per spot and no new data source.

import (
	"fmt"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"hamlog/internal/applog"
	"hamlog/internal/bandopen"
	"hamlog/internal/cluster"

	wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)

type bandOpenState struct {
	// on mirrors the "Watch for band openings" setting.
	//
	// Cached rather than read per spot: this is the cluster hot path, where a
	// settings query per spot is exactly what the rest of this file avoids.
	// startBandOpenFeed owns it — it runs at startup and again on every save, so
	// the switch takes effect without a restart.
	on atomic.Bool
	// bands is the operator's SELECTED set, held as map[string]bool.
	//
	// bandopen.Watched only says which bands the detector is capable of; it
	// knows nothing about the chips in Settings. Nothing checked the selection
	// on either feed path, so unticking 10 m and 2 m changed the subscription and
	// left the cluster path announcing them anyway.
	bands atomic.Value
	mu    sync.Mutex
	det  *bandopen.Detector
	last []bandopen.Opening // most recent first, for the UI
	// live holds the announced openings that are still going, keyed by band, and
	// aliveUntil says when each stops counting as current.
	//
	// The detector announces an opening ONCE and then goes quiet for 45 minutes,
	// which is right for a message but useless for a badge that has to stay lit
	// while the band is open and go out when it closes. Nothing tells us an
	// opening ended, so it is inferred: every qualifying spot on that band pushes
	// the deadline out, and when they stop arriving the badge fades by itself.
	live       map[string]bandopen.Opening
	aliveUntil map[string]time.Time
}

// openingIdle is how long a band may go without a qualifying spot before its
// badge goes out. Longer than the detector's own 12-minute window, so a quiet
// couple of minutes mid-opening does not blink the badge off and on again.
const openingIdle = 15 * time.Minute

const maxRememberedOpenings = 20

// detectBandOpening feeds one spot to the detector and announces a hit.
func (a *App) detectBandOpening(s cluster.Spot) {
	// The watch has to be switched on.
	//
	// It was not checked here at all: the setting only ever governed the extra
	// DATA SOURCES (the RBN nodes and the PSK Reporter feed), while the detector
	// itself ran on every ordinary cluster spot. So an operator who had never
	// enabled the watch still got opening banners, from a feature they had
	// deliberately left off.
	if !a.bandOpen.on.Load() || !a.bandOpenWanted(s.Band) {
		return
	}
	// No operator grid = no distance and no bearing on the spot, and the whole
	// detection rests on those two. Say nothing rather than guess.
	if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(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.DXCall, Band: s.Band, DistKm: s.DistanceKm,
		Bearing: s.ShortPath, At: s.ReceivedAt,
	}, a.opLat)
	if op != nil {
		a.rememberOpening(*op)
	}
	// Keeps a lit badge lit. Gated on the same floor the detector uses, or a
	// band busy with short-range tropo would hold an Es badge on for ever.
	if s.DistanceKm >= bandopen.DefaultConfig().MinKm {
		a.markBandAlive(s.Band, s.ReceivedAt)
	}
	a.bandOpen.mu.Unlock()
	if op != nil {
		a.announceOpening(*op)
	}
}

// markBandAlive pushes a band's badge deadline out. Called for every spot the
// detector accepted, from either feed. Cheap on purpose: this runs on the MQTT
// goroutine at thousands a minute when 6 m is open.
//
// Caller holds bandOpen.mu.
func (a *App) markBandAlive(band string, at time.Time) {
	if _, lit := a.bandOpen.live[strings.ToLower(band)]; !lit {
		return // nothing announced for this band, nothing to keep alive
	}
	if a.bandOpen.aliveUntil == nil {
		a.bandOpen.aliveUntil = map[string]time.Time{}
	}
	a.bandOpen.aliveUntil[strings.ToLower(band)] = at.Add(openingIdle)
}

// rememberOpening files a detection and lights its badge. Caller holds the mutex.
func (a *App) rememberOpening(op bandopen.Opening) {
	a.bandOpen.last = append([]bandopen.Opening{op}, a.bandOpen.last...)
	if len(a.bandOpen.last) > maxRememberedOpenings {
		a.bandOpen.last = a.bandOpen.last[:maxRememberedOpenings]
	}
	if a.bandOpen.live == nil {
		a.bandOpen.live = map[string]bandopen.Opening{}
		a.bandOpen.aliveUntil = map[string]time.Time{}
	}
	b := strings.ToLower(op.Band)
	a.bandOpen.live[b] = op
	a.bandOpen.aliveUntil[b] = op.At.Add(openingIdle)
}

// GetLiveOpenings returns the openings still under way, for the status-bar
// badge. Expired ones are dropped as they are noticed — there is no janitor for
// something that holds at most five entries.
func (a *App) GetLiveOpenings() []bandopen.Opening {
	now := time.Now()
	a.bandOpen.mu.Lock()
	defer a.bandOpen.mu.Unlock()
	out := make([]bandopen.Opening, 0, len(a.bandOpen.live))
	for b, op := range a.bandOpen.live {
		if until, ok := a.bandOpen.aliveUntil[b]; !ok || now.After(until) {
			delete(a.bandOpen.live, b)
			delete(a.bandOpen.aliveUntil, b)
			applog.Printf("bandopen: %s opening has gone quiet", strings.ToUpper(b))
			continue
		}
		out = append(out, op)
	}
	return out
}

// bandOpenWanted reports whether the operator has this band ticked. Falls back
// to the detector's own set until a selection has been stored, so a band is
// never silently dropped before the settings have been read.
func (a *App) bandOpenWanted(band string) bool {
	if !bandopen.Watched(band) {
		return false
	}
	sel, ok := a.bandOpen.bands.Load().(map[string]bool)
	if !ok || len(sel) == 0 {
		return true
	}
	return sel[strings.ToLower(strings.TrimSpace(band))]
}

// setBandOpenBands records the selection and puts out badges for bands that
// have just been unticked — they fade on a timer fed by spots the detector no
// longer looks at, so they would otherwise stay lit until the next restart.
func (a *App) setBandOpenBands(bands []string) {
	sel := make(map[string]bool, len(bands))
	for _, b := range bands {
		sel[strings.ToLower(strings.TrimSpace(b))] = true
	}
	a.bandOpen.bands.Store(sel)

	a.bandOpen.mu.Lock()
	defer a.bandOpen.mu.Unlock()
	for b := range a.bandOpen.live {
		if len(sel) > 0 && !sel[b] {
			delete(a.bandOpen.live, b)
			delete(a.bandOpen.aliveUntil, b)
			applog.Printf("bandopen: %s is no longer watched — badge cleared", strings.ToUpper(b))
		}
	}
}

// clearBandOpenings puts out every lit badge and forgets the detector's window.
// Called when the watch is switched off: the badges fade on a timer fed by
// spots the detector no longer looks at, so without this they would stay up
// until the next restart. The remembered list is left alone — those openings
// really did happen, and the operator may still want to see what he missed.
func (a *App) clearBandOpenings() {
	a.bandOpen.mu.Lock()
	defer a.bandOpen.mu.Unlock()
	a.bandOpen.live = nil
	a.bandOpen.aliveUntil = nil
	// Drop the accumulated spot window too, so switching the watch back on starts
	// from what is on the air now rather than from an hour-old burst.
	a.bandOpen.det = nil
}

// announceOpening logs and pushes one detection. Shared by both feeds — the
// cluster path here and the PSK Reporter path in bandopen_sources.go — so an
// opening reads the same however it was noticed.
func (a *App) announceOpening(op bandopen.Opening) {
	applog.Printf("bandopen: %s opening — %d stations, ~%d km, %s%s (%s)",
		op.Band, op.Calls, op.MedianKm, op.Sector(),
		map[bool]string{true: "", false: " — UNUSUAL for the season"}[op.InSeason],
		strings.Join(op.Examples, " "))
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "bandopen:detected", op)
	}
}

// GetBandOpenings returns the openings seen this session, newest first. The UI
// polls this so a detection is still visible after its toast has gone.
func (a *App) GetBandOpenings() []bandopen.Opening {
	a.bandOpen.mu.Lock()
	defer a.bandOpen.mu.Unlock()
	out := make([]bandopen.Opening, len(a.bandOpen.last))
	copy(out, a.bandOpen.last)
	return out
}

// BandOpeningSummary is the one-line form used in the toast and the log.
func BandOpeningSummary(o bandopen.Opening) string {
	s := fmt.Sprintf("%s open — %d stations ~%d km, %s", strings.ToUpper(o.Band), o.Calls, o.MedianKm, o.Sector())
	if !o.InSeason {
		s += " (unusual for the season)"
	}
	return s
}
