package main

// Satellites — the wiring around internal/sat.
//
// The package knows orbits and frequency plans; this file is what the station
// knows: where the antenna is, which birds the operator cares about, and where
// the elements are kept. Nothing here talks to a radio or a rotator yet — that
// is the next layer, and it is deliberately built on top of GetSatelliteTuning
// rather than beside it, so what the operator reads on screen and what gets
// sent to the rig can never disagree.

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"

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

	"hamlog/internal/applog"
	"hamlog/internal/sat"
)

const (
	keySatFavorites = "sat.favorites" // comma-separated satellite names
	keySatMinEl     = "sat.min_el"    // degrees; passes lower than this are not listed
	keySatWindowH   = "sat.window_h"  // hours of pass predictions
	keySatAutoTLE   = "sat.auto_tle"  // fetch elements at startup when the set is stale
	keySatGrid      = "sat.grid"      // locator override ("" = the station's own)
	keySatAltM      = "sat.alt_m"     // antenna height above sea level, metres

	// The az/el rotator. Its own settings rather than the HF rotator's: a
	// satellite station's elevation rotator is a different machine on a
	// different port, and an operator who has both must not have to choose.
	keySatRotOn = "sat.rot_enabled"
	// Which program drives the mast: OpsLog itself over EasyComm, or PstRotator,
	// which many stations already run in front of their controller. Its own port
	// key because it is a different program on a different port from an EasyComm
	// controller, and an operator who tries both must not lose the first setting
	// to the second.
	keySatRotType      = "sat.rot_type"      // "easycomm" | "pstrotator"
	keySatRotPstPort   = "sat.rot_pst_port"  // PstRotator's UDP command port
	keySatRotTransport = "sat.rot_transport" // "serial" | "tcp"
	keySatRotHost      = "sat.rot_host"
	keySatRotPort      = "sat.rot_port"
	keySatRotCOM       = "sat.rot_com"
	keySatRotBaud      = "sat.rot_baud"
	keySatRotMaxAz     = "sat.rot_max_az" // 360 or 450
	keySatRotMinEl     = "sat.rot_min_el" // don't drive the rotator below this elevation
	keySatRotStep      = "sat.rot_step"   // degrees of change worth a command
	keySatRotPark      = "sat.rot_park"   // park at az 0 / el 0 when tracking stops
)

// customTLEName holds elements the operator pasted in by hand.
//
// Kept apart from the feed cache because the cache is REPLACED wholesale on
// every refresh: a freshly launched satellite, whose elements arrive on a
// mailing list days before any feed carries it, would be wiped by the first
// automatic update — which is precisely the week everybody wants to hear it.
const customTLEName = "satellites.custom.tle"

// SatSettings is the station's side of satellite work.
type SatSettings struct {
	Favorites []string `json:"favorites"`
	MinEl     int      `json:"min_el"`
	WindowH   int      `json:"window_h"`
	AutoTLE   bool     `json:"auto_tle"`
	Grid      string   `json:"grid"`
	AltM      int      `json:"alt_m"`

	// The az/el rotator.
	RotOn        bool   `json:"rot_on"`
	RotType      string `json:"rot_type"`
	RotPstPort   int    `json:"rot_pst_port"`
	RotTransport string `json:"rot_transport"`
	RotHost      string `json:"rot_host"`
	RotPort      int    `json:"rot_port"`
	RotCOM       string `json:"rot_com"`
	RotBaud      int    `json:"rot_baud"`
	RotMaxAz     int    `json:"rot_max_az"`
	RotMinEl     int    `json:"rot_min_el"`
	RotStep      int    `json:"rot_step"`
	RotPark      bool   `json:"rot_park"`
}

// SatTransponder is one path through a satellite, as the UI needs it.
type SatTransponder struct {
	Label     string  `json:"label"`
	Mode      string  `json:"mode"`
	DownLo    int64   `json:"down_lo"`
	DownHi    int64   `json:"down_hi"`
	UpLo      int64   `json:"up_lo"`
	UpHi      int64   `json:"up_hi"`
	Inverting bool    `json:"inverting"`
	CTCSS     float64 `json:"ctcss"`
	Linear    bool    `json:"linear"`
}

// SatBird is a satellite as the operator sees it: the frequency plan joined to
// whatever elements we hold for it.
type SatBird struct {
	Name          string           `json:"name"`
	NORAD         int              `json:"norad"`
	Geostationary bool             `json:"geostationary"`
	Favorite      bool             `json:"favorite"`
	HasElements   bool             `json:"has_elements"`
	ElementName   string           `json:"element_name"` // the feed's spelling, when it differs
	EpochAgeH     float64          `json:"epoch_age_h"`
	Transponders  []SatTransponder `json:"transponders"`
}

// SatTLEInfo describes the element set the station is working from.
type SatTLEInfo struct {
	Count     int       `json:"count"`
	FetchedAt time.Time `json:"fetched_at"`
	AgeH      float64   `json:"age_h"`
	Stale     bool      `json:"stale"`
	Custom    int       `json:"custom"` // hand-entered satellites among the count
}

// SatTuning is where to listen and where to transmit, right now.
//
// Both the nominal and the corrected pair are returned on purpose: the nominal
// is what goes in the log (see the ADIF note on SAT_NAME) and the corrected is
// what goes to the radio. An operator staring at a display that shows only one
// of them cannot tell a Doppler correction from a mistuned transponder.
type SatTuning struct {
	Name        string  `json:"name"`
	Transponder string  `json:"transponder"`
	Mode        string  `json:"mode"`
	NominalDown int64   `json:"nominal_down"`
	NominalUp   int64   `json:"nominal_up"`
	DownHz      int64   `json:"down_hz"`
	UpHz        int64   `json:"up_hz"`
	CTCSS       float64 `json:"ctcss"`
	Inverting   bool    `json:"inverting"`

	Az        float64   `json:"az"`
	El        float64   `json:"el"`
	RangeKm   float64   `json:"range_km"`
	RangeRate float64   `json:"range_rate"`
	Visible   bool      `json:"visible"`
	At        time.Time `json:"at"`

	// Where the satellite is over the earth. Carried with the tuning because
	// they are read together and change together — the panel would otherwise ask
	// twice a second for two halves of one instant.
	Lat       float64 `json:"lat"`
	Lon       float64 `json:"lon"`
	AltKm     float64 `json:"alt_km"`
	Footprint float64 `json:"footprint_km"`
}

// SatPassInfo is the pass in progress, or the next one.
//
// Separate from the tuning and polled far more slowly: predicting a pass steps
// the orbit thirty seconds at a time across hours, which is not something to do
// once a second for a countdown a browser can run itself from two timestamps.
type SatPassInfo struct {
	Name    string `json:"name"`
	HasPass bool   `json:"has_pass"`
	// InPass distinguishes "it is up now" from "it rises at". The pass in
	// progress is reported whatever its maximum elevation: an operator watching
	// a satellite go over does not want it hidden because it fell below the
	// threshold that filters the TABLE of what is worth waiting for.
	InPass   bool      `json:"in_pass"`
	AOS      time.Time `json:"aos"`
	LOS      time.Time `json:"los"`
	AOSAz    float64   `json:"aos_az"`
	LOSAz    float64   `json:"los_az"`
	MaxEl    float64   `json:"max_el"`
	MaxElAz  float64   `json:"max_el_az"`
	MaxElAt  time.Time `json:"max_el_at"`
	Duration float64   `json:"duration_s"`
}

// ── Lifecycle ───────────────────────────────────────────────────────────────

// startSatellites loads what is already on disk and, only if asked, goes to the
// network.
//
// Cache first and synchronously: it is one file and a few hundred parses, and
// it means the satellite tab is populated the instant it is opened, on a shack
// PC with no internet as much as on one with. The fetch is the slow, optional
// half and never blocks a launch.
func (a *App) startSatellites() {
	dir := a.dataDir
	birds, err := sat.LoadBirds(dir)
	if err != nil {
		// LoadBirds always returns a usable list; the error says the operator's
		// own file was refused, which they need to be told about.
		applog.Printf("sat: %v", err)
	}
	store := sat.NewStore()
	fetch := sat.NewFetcher(dir)
	fetch.Logf = applog.Printf

	if els, at, err := fetch.LoadCache(); err == nil {
		store.Replace(els, at)
		applog.Printf("sat: %d satellites from the cached element set (%s old)", len(els), time.Since(at).Round(time.Minute))
	} else if !os.IsNotExist(err) {
		applog.Printf("sat: the cached element set could not be read: %v", err)
	}
	a.satMu.Lock()
	a.satStore, a.satBirds, a.satFetch = store, birds, fetch
	a.satMu.Unlock()
	a.loadCustomElements()

	set := a.satSettings()
	if set.AutoTLE && a.satTLEInfo().Stale {
		go func() {
			if _, err := a.RefreshSatelliteTLE(); err != nil {
				applog.Printf("sat: %v", err)
			}
		}()
	}
}

// satParts hands back the three pieces under the lock, building them if the
// startup path has not run — a binding called from a tab the operator opened
// before startup finished must not answer "no satellites".
func (a *App) satParts() (*sat.Store, *sat.Birds, *sat.Fetcher) {
	a.satMu.Lock()
	if a.satStore == nil {
		a.satMu.Unlock()
		a.startSatellites()
		a.satMu.Lock()
	}
	s, b, f := a.satStore, a.satBirds, a.satFetch
	a.satMu.Unlock()
	return s, b, f
}

// ── Settings ────────────────────────────────────────────────────────────────

func (a *App) satSettings() SatSettings {
	// The rotator defaults are the common case, not a blank form: EasyComm over
	// a serial port at 9600, a 360° machine, and a five-degree step — which on a
	// beam with any gain at all is well inside the beamwidth and keeps a pass
	// from being a command a second.
	out := SatSettings{
		MinEl: 10, WindowH: 24, AutoTLE: true,
		RotType: satRotEasycomm, RotPstPort: 12000,
		RotTransport: "serial", RotPort: 4533, RotBaud: 9600,
		RotMaxAz: 360, RotMinEl: 0, RotStep: 5,
	}
	if a.settings == nil {
		return out
	}
	m, err := a.settings.GetMany(a.ctx,
		keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
		keySatRotOn, keySatRotType, keySatRotPstPort, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM,
		keySatRotBaud, keySatRotMaxAz, keySatRotMinEl, keySatRotStep, keySatRotPark)
	if err != nil {
		return out
	}
	out.RotOn = m[keySatRotOn] == "1"
	if ty := m[keySatRotType]; ty == satRotPst || ty == satRotEasycomm {
		out.RotType = ty
	}
	if v, err := strconv.Atoi(m[keySatRotPstPort]); err == nil && v > 0 && v <= 65535 {
		out.RotPstPort = v
	}
	if tr := m[keySatRotTransport]; tr == "tcp" || tr == "serial" {
		out.RotTransport = tr
	}
	out.RotHost = strings.TrimSpace(m[keySatRotHost])
	if v, err := strconv.Atoi(m[keySatRotPort]); err == nil && v > 0 && v <= 65535 {
		out.RotPort = v
	}
	out.RotCOM = strings.TrimSpace(m[keySatRotCOM])
	if v, err := strconv.Atoi(m[keySatRotBaud]); err == nil && v >= 1200 && v <= 115200 {
		out.RotBaud = v
	}
	if v, err := strconv.Atoi(m[keySatRotMaxAz]); err == nil && v == 450 {
		out.RotMaxAz = 450
	}
	if v, err := strconv.Atoi(m[keySatRotMinEl]); err == nil && v >= -10 && v <= 30 {
		out.RotMinEl = v
	}
	if v, err := strconv.Atoi(m[keySatRotStep]); err == nil && v >= 1 && v <= 30 {
		out.RotStep = v
	}
	out.RotPark = m[keySatRotPark] == "1"
	for _, n := range strings.Split(m[keySatFavorites], ",") {
		if n = strings.TrimSpace(n); n != "" {
			out.Favorites = append(out.Favorites, n)
		}
	}
	if v, err := strconv.Atoi(m[keySatMinEl]); err == nil && v >= 0 && v <= 60 {
		out.MinEl = v
	}
	if v, err := strconv.Atoi(m[keySatWindowH]); err == nil && v >= 1 && v <= 168 {
		out.WindowH = v
	}
	if v, ok := m[keySatAutoTLE]; ok && v != "" {
		out.AutoTLE = v == "1"
	}
	out.Grid = strings.TrimSpace(m[keySatGrid])
	if v, err := strconv.Atoi(m[keySatAltM]); err == nil && v > -500 && v < 9000 {
		out.AltM = v
	}
	return out
}

// GetSatSettings returns the satellite preferences.
func (a *App) GetSatSettings() (SatSettings, error) {
	if a.settings == nil {
		return SatSettings{}, fmt.Errorf("db not initialized")
	}
	return a.satSettings(), nil
}

// SaveSatSettings stores them.
func (a *App) SaveSatSettings(s SatSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if s.MinEl < 0 || s.MinEl > 60 {
		s.MinEl = 10
	}
	if s.WindowH < 1 || s.WindowH > 168 {
		s.WindowH = 24
	}
	var favs []string
	seen := map[string]bool{}
	for _, n := range s.Favorites {
		n = strings.TrimSpace(n)
		if n == "" || seen[strings.ToUpper(n)] {
			continue
		}
		seen[strings.ToUpper(n)] = true
		favs = append(favs, n)
	}
	if s.RotType != satRotPst {
		s.RotType = satRotEasycomm
	}
	if s.RotPstPort <= 0 || s.RotPstPort > 65535 {
		s.RotPstPort = 12000
	}
	if s.RotTransport != "tcp" {
		s.RotTransport = "serial"
	}
	if s.RotMaxAz != 450 {
		s.RotMaxAz = 360
	}
	if s.RotStep < 1 || s.RotStep > 30 {
		s.RotStep = 5
	}
	if s.RotPort <= 0 || s.RotPort > 65535 {
		s.RotPort = 4533
	}
	if s.RotBaud < 1200 || s.RotBaud > 115200 {
		s.RotBaud = 9600
	}
	for k, v := range map[string]string{
		keySatFavorites:    strings.Join(favs, ","),
		keySatMinEl:        strconv.Itoa(s.MinEl),
		keySatWindowH:      strconv.Itoa(s.WindowH),
		keySatAutoTLE:      boolStr(s.AutoTLE),
		keySatGrid:         strings.ToUpper(strings.TrimSpace(s.Grid)),
		keySatAltM:         strconv.Itoa(s.AltM),
		keySatRotOn:        boolStr(s.RotOn),
		keySatRotType:      s.RotType,
		keySatRotPstPort:   strconv.Itoa(s.RotPstPort),
		keySatRotTransport: s.RotTransport,
		keySatRotHost:      strings.TrimSpace(s.RotHost),
		keySatRotPort:      strconv.Itoa(s.RotPort),
		keySatRotCOM:       strings.TrimSpace(s.RotCOM),
		keySatRotBaud:      strconv.Itoa(s.RotBaud),
		keySatRotMaxAz:     strconv.Itoa(s.RotMaxAz),
		keySatRotMinEl:     strconv.Itoa(s.RotMinEl),
		keySatRotStep:      strconv.Itoa(s.RotStep),
		keySatRotPark:      boolStr(s.RotPark),
	} {
		if err := a.settings.Set(a.ctx, k, v); err != nil {
			return err
		}
	}
	return nil
}

// satObserver is the ground station: the satellite grid if the operator set one,
// otherwise the station's own.
//
// A locator, not a latitude and longitude: it is what every logbook already
// holds, and its six-character precision is a couple of kilometres — three
// hundredths of a degree of azimuth at the worst possible geometry, far below
// any rotator's backlash.
func (a *App) satObserver() (sat.Observer, error) {
	set := a.satSettings()
	grid := set.Grid
	if grid == "" && a.profiles != nil {
		// The station locator lives on the ACTIVE PROFILE, not in a settings key.
		// keyStationMyGrid is a legacy key that EnsureDefault migrated into the
		// profile years ago and nothing writes any more — reading it told an
		// operator with a perfectly good locator on screen that he had not set
		// one.
		if p, err := a.profiles.Active(a.ctx); err == nil {
			grid = p.MyGrid
		}
	}
	grid = strings.TrimSpace(grid)
	lat, lon, ok := gridToLatLon(grid)
	if !ok {
		return sat.Observer{}, fmt.Errorf("your locator is not set — Settings ▸ Station, or Settings ▸ Satellites for a different site")
	}
	return sat.Observer{Lat: lat, Lon: lon, AltM: float64(set.AltM)}, nil
}

// GetSatelliteObserver reports the ground station the predictions are made for,
// so the UI can show it — and say plainly when there is none.
func (a *App) GetSatelliteObserver() (map[string]any, error) {
	obs, err := a.satObserver()
	if err != nil {
		return nil, err
	}
	return map[string]any{"lat": obs.Lat, "lon": obs.Lon, "alt_m": obs.AltM}, nil
}

// ── Elements ────────────────────────────────────────────────────────────────

func (a *App) customTLEPath() string { return filepath.Join(a.dataDir, customTLEName) }

// loadCustomElements merges the hand-entered file over the feed's set. Last
// writer wins in the store, so an operator's own elements for a satellite
// override the feed's — which is the whole point of having typed them.
func (a *App) loadCustomElements() int {
	f, err := os.Open(a.customTLEPath())
	if err != nil {
		return 0
	}
	defer f.Close()
	els, skipped, err := sat.ParseTLESet(f)
	if err != nil {
		applog.Printf("sat: %s could not be read: %v", customTLEName, err)
		return 0
	}
	if skipped > 0 {
		applog.Printf("sat: %d entries in %s were unusable", skipped, customTLEName)
	}
	store, _, _ := a.satParts()
	for _, e := range els {
		store.Put(e)
	}
	return len(els)
}

func (a *App) customElementCount() int {
	f, err := os.Open(a.customTLEPath())
	if err != nil {
		return 0
	}
	defer f.Close()
	els, _, err := sat.ParseTLESet(f)
	if err != nil {
		return 0
	}
	return len(els)
}

func (a *App) satTLEInfo() SatTLEInfo {
	store, _, _ := a.satParts()
	at := store.FetchedAt()
	info := SatTLEInfo{Count: store.Len(), FetchedAt: at, Custom: a.customElementCount()}
	if !at.IsZero() {
		info.AgeH = time.Since(at).Hours()
		info.Stale = time.Since(at) > sat.StaleAfter
	} else {
		info.Stale = true // nothing on disk yet: the operator has to be told to fetch
	}
	return info
}

// GetSatelliteTLEInfo describes the element set, including how old it is.
func (a *App) GetSatelliteTLEInfo() SatTLEInfo { return a.satTLEInfo() }

// RefreshSatelliteTLE downloads a fresh element set.
func (a *App) RefreshSatelliteTLE() (SatTLEInfo, error) {
	store, _, fetch := a.satParts()
	ctx := a.ctx
	if ctx == nil {
		ctx = context.Background()
	}
	els, err := fetch.Fetch(ctx)
	if err != nil {
		return a.satTLEInfo(), err
	}
	store.Replace(els, time.Now())
	a.loadCustomElements() // the operator's own elements go back on top
	info := a.satTLEInfo()
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "sat:tle", info)
	}
	return info, nil
}

// AddSatelliteElements takes elements pasted in by hand — two or three lines
// per satellite — and keeps them across feed refreshes.
func (a *App) AddSatelliteElements(text string) (int, error) {
	els, skipped, err := sat.ParseTLESet(strings.NewReader(text))
	if err != nil {
		return 0, fmt.Errorf("those are not usable elements: %w", err)
	}
	existing := map[string]bool{}
	var keep []sat.Element
	if f, ferr := os.Open(a.customTLEPath()); ferr == nil {
		old, _, _ := sat.ParseTLESet(f)
		f.Close()
		keep = old
	}
	// The new set wins for a satellite already in the file: pasting elements is
	// how an operator UPDATES a bird the feeds do not carry.
	for _, e := range els {
		existing[strings.ToUpper(e.Name)] = true
	}
	var out []sat.Element
	for _, e := range keep {
		if !existing[strings.ToUpper(e.Name)] {
			out = append(out, e)
		}
	}
	out = append(out, els...)

	var b strings.Builder
	for _, e := range out {
		if e.Name != "" {
			b.WriteString(e.Name + "\n")
		}
		b.WriteString(e.Line1 + "\n" + e.Line2 + "\n")
	}
	if err := os.WriteFile(a.customTLEPath(), []byte(b.String()), 0o644); err != nil {
		return 0, err
	}
	n := a.loadCustomElements()
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "sat:tle", a.satTLEInfo())
	}
	if skipped > 0 {
		applog.Printf("sat: %d pasted entries were unusable and were skipped", skipped)
	}
	return n, nil
}

// ── The list ────────────────────────────────────────────────────────────────

// GetSatelliteBirds joins the frequency plan to the elements.
//
// Both halves are listed, not just their intersection: a bird with elements and
// no plan is one the operator can still track and add frequencies for, and a
// bird with a plan and no elements is the one visible symptom of an element set
// that is too old or too narrow — silently dropping either turns a fixable
// configuration problem into a satellite that "does not exist".
func (a *App) GetSatelliteBirds() []SatBird {
	store, birds, _ := a.satParts()
	set := a.satSettings()
	fav := map[string]bool{}
	for _, n := range set.Favorites {
		fav[strings.ToUpper(n)] = true
	}

	out := make([]SatBird, 0, birds.Len())
	planned := map[string]bool{}
	for _, b := range birds.All() {
		item := SatBird{Name: b.Name, Geostationary: b.Geostationary, Favorite: fav[strings.ToUpper(b.Name)]}
		for _, t := range b.Transponders {
			item.Transponders = append(item.Transponders, SatTransponder{
				Label: t.Label, Mode: t.Mode,
				DownLo: t.DownLo, DownHi: t.DownHi, UpLo: t.UpLo, UpHi: t.UpHi,
				Inverting: t.Inverting, CTCSS: t.CTCSS, Linear: t.Linear(),
			})
		}
		if e, ok := satElement(store, b); ok {
			item.HasElements = true
			item.NORAD = e.NORAD
			item.EpochAgeH = e.Age().Hours()
			planned[strings.ToUpper(e.Name)] = true
			if !strings.EqualFold(e.Name, b.Name) {
				item.ElementName = e.Name
			}
		}
		out = append(out, item)
	}
	// The rest of the element set, so nothing the station holds is invisible.
	for _, n := range store.Names() {
		if planned[strings.ToUpper(n)] {
			continue
		}
		e, ok := store.Get(n)
		if !ok {
			continue
		}
		out = append(out, SatBird{
			Name: e.Name, NORAD: e.NORAD, HasElements: true,
			EpochAgeH: e.Age().Hours(), Favorite: fav[strings.ToUpper(e.Name)],
		})
	}
	sort.Slice(out, func(i, j int) bool {
		// Favourites first, then the birds we can actually use, then by name.
		if out[i].Favorite != out[j].Favorite {
			return out[i].Favorite
		}
		iu := len(out[i].Transponders) > 0 && out[i].HasElements
		ju := len(out[j].Transponders) > 0 && out[j].HasElements
		if iu != ju {
			return iu
		}
		return out[i].Name < out[j].Name
	})
	return out
}

// GetSatelliteNames is the list behind the entry form's SAT_NAME box.
//
// One list, not two. It used to be a text box in Settings ▸ Lists that an
// operator typed their birds into by hand, which then had nothing to do with
// the satellites the tracker knew — the same station kept two lists of the same
// satellites and they drifted apart. This is the followed set (or every
// satellite with a frequency plan, when none is followed), plus anything the
// old hand-kept list still holds so nobody's typing is thrown away.
//
// SAT_NAME is compared character for character by the awards and by LoTW, so
// offering the spelling already used beats inventing a new one every pass.
func (a *App) GetSatelliteNames() []string {
	seen := map[string]bool{}
	var out []string
	add := func(n string) {
		n = strings.ToUpper(strings.TrimSpace(n))
		if n == "" || seen[n] {
			return
		}
		seen[n] = true
		out = append(out, n)
	}
	for _, n := range a.satNames(nil) {
		add(n)
	}
	// The legacy list. Read, never written: the panel that edited it is gone,
	// and what it holds is somebody's past work.
	if a.settings != nil {
		if raw, _ := a.settings.Get(a.ctx, keyListsSatellites); raw != "" {
			var legacy []string
			if json.Unmarshal([]byte(raw), &legacy) == nil {
				for _, n := range legacy {
					add(n)
				}
			}
		}
	}
	sort.Strings(out)
	return out
}

// satElement finds the elements for a bird, trying its aliases.
//
// The feed's name and the operator's name for the same satellite are routinely
// different, and the element set is keyed by the feed's.
func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
	if e, ok := store.Get(b.Name); ok {
		return e, true
	}
	for _, alias := range b.Aliases {
		if e, ok := store.Get(alias); ok {
			return e, true
		}
	}
	// Last resort: scan, matching on letters and digits alone — that is how
	// "RADFXSAT (FOX-1B)" and "AO-91" meet.
	for _, n := range store.Names() {
		if b.Matches(n) {
			if e, ok := store.Get(n); ok {
				return e, true
			}
		}
	}
	return sat.Element{}, false
}

// ── Tracking ────────────────────────────────────────────────────────────────

// satNames resolves the names the UI asked for, falling back to the favourites
// and then to every planned bird we hold elements for.
func (a *App) satNames(names []string) []string {
	if len(names) > 0 {
		return names
	}
	set := a.satSettings()
	if len(set.Favorites) > 0 {
		return set.Favorites
	}
	var out []string
	for _, b := range a.GetSatelliteBirds() {
		if b.HasElements && len(b.Transponders) > 0 {
			out = append(out, b.Name)
		}
	}
	return out
}

// satResolve maps an operator-facing name onto the element set's own spelling.
func (a *App) satResolve(name string) (string, bool) {
	store, birds, _ := a.satParts()
	if _, ok := store.Get(name); ok {
		return name, true
	}
	if b, ok := birds.Find(name); ok {
		if e, ok2 := satElement(store, b); ok2 {
			return e.Name, true
		}
	}
	return "", false
}

// GetSatellitePositions is where the given satellites are right now — the map's
// question, and the rotator's.
func (a *App) GetSatellitePositions(names []string) ([]sat.Position, error) {
	obs, err := a.satObserver()
	if err != nil {
		return nil, err
	}
	store, _, _ := a.satParts()
	now := time.Now().UTC()
	var out []sat.Position
	for _, n := range a.satNames(names) {
		real, ok := a.satResolve(n)
		if !ok {
			continue
		}
		p, err := store.Track(real, obs, now)
		if err != nil {
			continue
		}
		p.Name = n // answer in the operator's vocabulary, not the feed's
		out = append(out, p)
	}
	return out, nil
}

// GetSatelliteGroundTrack is the path a satellite draws over the ground, for
// the map: one point a minute, forward from now.
func (a *App) GetSatelliteGroundTrack(name string, minutes int) ([]sat.Position, error) {
	if minutes <= 0 || minutes > 360 {
		minutes = 120
	}
	obs, err := a.satObserver()
	if err != nil {
		return nil, err
	}
	real, ok := a.satResolve(name)
	if !ok {
		return nil, fmt.Errorf("%s is not in the element set", name)
	}
	store, _, _ := a.satParts()
	now := time.Now().UTC()
	out := make([]sat.Position, 0, minutes+1)
	for i := 0; i <= minutes; i++ {
		p, err := store.Track(real, obs, now.Add(time.Duration(i)*time.Minute))
		if err != nil {
			return nil, err
		}
		p.Name = name
		out = append(out, p)
	}
	return out, nil
}

// GetSatellitePasses lists what is coming, in time order.
func (a *App) GetSatellitePasses(names []string, hours int) ([]sat.Pass, error) {
	obs, err := a.satObserver()
	if err != nil {
		return nil, err
	}
	set := a.satSettings()
	if hours <= 0 {
		hours = set.WindowH
	}
	if hours > 168 {
		hours = 168
	}
	store, _, _ := a.satParts()
	want := a.satNames(names)
	// The store is keyed by the feed's names; remember which operator name each
	// answer belongs to so the table reads the way the operator thinks.
	real := make([]string, 0, len(want))
	back := map[string]string{}
	for _, n := range want {
		r, ok := a.satResolve(n)
		if !ok {
			continue
		}
		real = append(real, r)
		back[r] = n
	}
	passes := store.NextPasses(real, obs, time.Now().UTC(), time.Duration(hours)*time.Hour, set.MinEl)
	for i := range passes {
		if n, ok := back[passes[i].Name]; ok {
			passes[i].Name = n
		}
	}
	return passes, nil
}

// SatSkyPoint is one moment of a pass as the antenna sees it.
type SatSkyPoint struct {
	At time.Time `json:"at"`
	Az float64   `json:"az"`
	El float64   `json:"el"`
}

// GetSatelliteSkyTrack is the pass drawn as a path across the sky.
//
// The map answers "where is it over the earth"; this answers "where do I look",
// which on a pass is the question that matters. An operator reading a polar
// plot knows in one glance whether the bird comes over the top or clips the
// horizon behind the house — something no amount of azimuth and elevation
// digits conveys.
func (a *App) GetSatelliteSkyTrack(name string, points int) ([]SatSkyPoint, error) {
	if points < 8 || points > 400 {
		points = 120
	}
	p, err := a.GetSatelliteNextPass(name)
	if err != nil {
		return nil, err
	}
	if !p.HasPass {
		return nil, nil
	}
	obs, err := a.satObserver()
	if err != nil {
		return nil, err
	}
	real, ok := a.satResolve(name)
	if !ok {
		return nil, fmt.Errorf("%s is not in the element set", name)
	}
	store, _, _ := a.satParts()
	span := p.LOS.Sub(p.AOS)
	if span <= 0 {
		return nil, nil
	}
	out := make([]SatSkyPoint, 0, points+1)
	for i := 0; i <= points; i++ {
		at := p.AOS.Add(time.Duration(float64(span) * float64(i) / float64(points)))
		pos, err := store.Track(real, obs, at)
		if err != nil {
			return nil, err
		}
		// Below the horizon at the very ends, by a fraction of a degree, because
		// the pass boundaries come from a coarser search than this sampling. A
		// negative elevation would draw the track outside the horizon circle.
		if pos.El < 0 {
			pos.El = 0
		}
		out = append(out, SatSkyPoint{At: at.UTC(), Az: pos.Az, El: pos.El})
	}
	return out, nil
}

// GetSatelliteNextPass is the pass in progress, or the next one to come.
//
// The one question that decides whether an operator sits down at the radio, and
// the reason a satellite tab is worth having at all: how long have I got, and
// how high does it get.
func (a *App) GetSatelliteNextPass(name string) (SatPassInfo, error) {
	out := SatPassInfo{Name: name}
	obs, err := a.satObserver()
	if err != nil {
		return out, err
	}
	real, ok := a.satResolve(name)
	if !ok {
		return out, fmt.Errorf("%s is not in the element set", name)
	}
	store, _, _ := a.satParts()
	now := time.Now().UTC()
	// From a little before now: a pass that started two minutes ago is the one
	// the operator is in, and asking from this instant would skip it and report
	// the next orbit instead — an hour and a half away, while the satellite is
	// overhead.
	from := now.Add(-30 * time.Minute)
	// Elevation zero, not the operator's minimum. That threshold filters the
	// table of passes worth waiting for; it must not hide the pass they are
	// actually working.
	passes, err := store.Passes(real, obs, from, now.Add(26*time.Hour), 0)
	if err != nil {
		return out, err
	}
	for _, p := range passes {
		if p.LOS.Before(now) {
			continue // already over
		}
		out.HasPass = true
		out.InPass = !p.AOS.After(now)
		out.AOS, out.LOS = p.AOS, p.LOS
		out.AOSAz, out.LOSAz = p.AOSAz, p.LOSAz
		out.MaxEl, out.MaxElAz, out.MaxElAt = p.MaxEl, p.MaxElAz, p.MaxElAt
		out.Duration = p.Duration
		return out, nil
	}
	return out, nil
}

// GetSatelliteTuning is the working answer: where to listen, where to transmit,
// and where the bird is, for one satellite and one transponder.
//
// downHz is where the operator has tuned inside the passband, in NOMINAL terms
// — 0 means the middle of it. Keeping the operator's frequency nominal, and
// applying Doppler only on the way out to the radio, is what makes a linear
// pass workable: the station being answered stays put on the dial while both
// radios chase the shift.
func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (SatTuning, error) {
	_, birds, _ := a.satParts()
	b, ok := birds.Find(name)
	if !ok {
		return SatTuning{}, fmt.Errorf("%s has no frequency plan — add one in %s", name, sat.BirdsName)
	}
	if transponder < 0 || transponder >= len(b.Transponders) {
		transponder = 0
	}
	if len(b.Transponders) == 0 {
		return SatTuning{}, fmt.Errorf("%s has no transponder listed", b.Name)
	}
	t := b.Transponders[transponder]
	if downHz <= 0 {
		// While the tracker is running it owns the nominal frequency — it moves
		// as the operator tunes. Reading the centre of the passband instead would
		// show a frequency nobody is on the moment they hunt for a station.
		downHz = a.satTrackedNominal(b.Name, transponder)
	}
	if downHz <= 0 {
		downHz = t.Centre()
	}
	out := SatTuning{
		Name:        b.Name,
		Transponder: t.Label,
		Mode:        t.Mode,
		NominalDown: downHz,
		NominalUp:   t.UplinkFor(downHz),
		CTCSS:       t.CTCSS,
		Inverting:   t.Inverting,
		At:          time.Now().UTC(),
	}
	// Geostationary: it does not move, so there is nothing to correct and no
	// look angle worth recomputing every second. QO-100 is simply pointed at
	// once and left alone.
	if b.Geostationary {
		out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
		out.Visible = true
		return out, nil
	}

	obs, err := a.satObserver()
	if err != nil {
		// No locator: the frequencies are still worth having, uncorrected.
		out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
		return out, nil
	}
	real, ok := a.satResolve(name)
	if !ok {
		out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
		return out, fmt.Errorf("%s is not in the element set — refresh the elements", b.Name)
	}
	store, _, _ := a.satParts()
	p, err := store.Track(real, obs, out.At)
	if err != nil {
		out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
		return out, err
	}
	sh := sat.Doppler(p, out.NominalDown, out.NominalUp)
	out.DownHz, out.UpHz = sh.DownHz, sh.UpHz
	out.Az, out.El, out.RangeKm, out.RangeRate = p.Az, p.El, p.RangeKm, p.RangeRate
	out.Lat, out.Lon, out.AltKm, out.Footprint = p.Lat, p.Lon, p.AltKm, p.Footprint
	out.Visible = p.Visible()
	return out, nil
}
