package main

// Chasing a split pile-up by the report the DX just sent.
//
// Working a DXpedition in split means guessing where it is listening. The DX
// answers one station, sends "5NN", and moves on; the useful information is
// therefore not the callsign it answered but the FREQUENCY that callsign was
// transmitting on, because the DX's receiver was there a second ago.
//
// A CW skimmer already knows this. SDC (Software Defined Connector) decodes the
// whole pile-up and marks each report on the panadapter as a spot — the marker
// TEXT is configured in SDC by the operator ("599" for a fresh report, "X" for
// an older one, by default). Those spots reach OpsLog already: it subscribes to
// the radio's spot feed, and every spot another program posts arrives on
// Flex.OnForeignSpot.
//
// So the whole feature is: recognise the marker, move the TRANSMIT slice there,
// leave the receive slice on the DX. Nothing here decodes anything.
//
// Three deliberate choices:
//
//   - the marker text is a SETTING, not a constant. It is chosen in SDC, and
//     any guess made here would be wrong for the operator who chose otherwise.
//   - only the transmit slice moves, never the receive slice. Losing the DX is
//     a worse outcome than a missed call.
//   - the offset is signed and in Hz, because working "up a bit" from where the
//     last station was answered is exactly how a pile-up is chased.

import (
	"encoding/json"
	"strconv"
	"strings"
	"sync"
	"time"

	"hamlog/internal/applog"
	"hamlog/internal/cat"
)

const keyFlexRSTChase = "flex.rst_chase"

// FlexRSTChase is the whole configuration.
type FlexRSTChase struct {
	Enabled bool `json:"enabled"`
	// Markers are the skimmer's marker texts, comma-separated ("599,5NN").
	// Matched against the spot's callsign field, case-insensitively and whole:
	// a spot IS the marker or it is an ordinary callsign, and a substring rule
	// would drag in any station whose call happens to contain the digits.
	Markers string `json:"markers"`
	// OffsetHz is added to the marker's frequency. Signed: chasing upward from
	// the last station worked is the usual tactic, downward happens too.
	OffsetHz int `json:"offset_hz"`
	// SplitOnly refuses to act when the radio is not in split. On by default:
	// out of split the transmit slice IS the receive slice, so "move the TX
	// slice" would take the operator off the DX they are listening to.
	SplitOnly bool `json:"split_only"`
}

var defaultFlexRSTChase = FlexRSTChase{Enabled: false, Markers: "599", OffsetHz: 0, SplitOnly: true}

// rstChaseMinGap throttles the moves. A skimmer marks every report it decodes,
// and a busy pile-up produces several a second; without a floor the transmit
// slice would twitch continuously and never be anywhere long enough to call.
const rstChaseMinGap = 700 * time.Millisecond

// rstChaseMinStep ignores a marker that lands where the slice already is.
// Re-sending the same frequency is not free: it is a command to the radio and a
// slice status back, several times a second, for no change at all.
const rstChaseMinStep = 20 // Hz

var (
	rstChaseMu   sync.Mutex
	rstChaseLast time.Time
	rstChaseFreq int64
)

// GetFlexRSTChase returns the stored configuration (defaults when unset).
func (a *App) GetFlexRSTChase() FlexRSTChase {
	s := defaultFlexRSTChase
	if a.settings == nil {
		return s
	}
	// settingOr, NOT settings.Get with profileScope(): the store already applies
	// the active profile's prefix, so scoping the key here wrote p3.flex.rst_chase
	// and read p3.p3.flex.rst_chase — the switch could never be read back on, and
	// the feature did nothing at all with no sign of why.
	if v := a.settingOr(keyFlexRSTChase, ""); strings.TrimSpace(v) != "" {
		_ = json.Unmarshal([]byte(v), &s)
	}
	return normRSTChase(s)
}

// SaveFlexRSTChase stores the configuration.
func (a *App) SaveFlexRSTChase(s FlexRSTChase) error {
	b, err := json.Marshal(normRSTChase(s))
	if err != nil {
		return err
	}
	a.setSetting(keyFlexRSTChase, string(b))
	return nil
}

// SetFlexRSTChaseEnabled flips the switch alone.
//
// Its own binding because this belongs on a button in the panel, not in a
// settings page: it is turned on when a DXpedition appears and off when it is
// worked, which is a thing done mid-QSO with one hand.
func (a *App) SetFlexRSTChaseEnabled(on bool) error {
	s := a.GetFlexRSTChase()
	s.Enabled = on
	return a.SaveFlexRSTChase(s)
}

func normRSTChase(s FlexRSTChase) FlexRSTChase {
	if strings.TrimSpace(s.Markers) == "" {
		s.Markers = defaultFlexRSTChase.Markers
	}
	// A pile-up is a few kHz wide. Anything past that is a typo (Hz entered as
	// if it were kHz), and honouring it would transmit far outside the segment.
	if s.OffsetHz > 10000 {
		s.OffsetHz = 10000
	}
	if s.OffsetHz < -10000 {
		s.OffsetHz = -10000
	}
	return s
}

// rstChaseMarkerSet splits the configured markers into a comparison set.
func rstChaseMarkerSet(markers string) map[string]bool {
	out := map[string]bool{}
	for _, m := range strings.FieldsFunc(markers, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
		if m = strings.ToUpper(strings.TrimSpace(m)); m != "" {
			out[m] = true
		}
	}
	return out
}

// handleForeignSpot is what a skimmer's spot arrives at.
func (a *App) handleForeignSpot(callsign string, freqHz int64) {
	cfg := a.GetFlexRSTChase()
	if !cfg.Enabled || a.cat == nil {
		return
	}
	if !rstChaseMarkerSet(cfg.Markers)[strings.ToUpper(strings.TrimSpace(callsign))] {
		return // an ordinary spot: another station's callsign, not a report
	}
	// From here on every refusal is logged. A marker WAS recognised, so the
	// operator is entitled to know why the slice stayed where it was — silence
	// at this point is indistinguishable from a feature that does not work.
	if cfg.SplitOnly && !a.cat.State().Split {
		applog.Printf("rst chase: %s marked at %s, but the radio is not in split — ignored",
			strings.ToUpper(callsign), hzText(freqHz))
		return
	}
	target := freqHz + int64(cfg.OffsetHz)
	if target <= 0 {
		return
	}
	now := time.Now()
	rstChaseMu.Lock()
	if now.Sub(rstChaseLast) < rstChaseMinGap {
		rstChaseMu.Unlock()
		return
	}
	if rstChaseFreq != 0 && absInt64(target-rstChaseFreq) < rstChaseMinStep {
		rstChaseMu.Unlock()
		return
	}
	rstChaseLast, rstChaseFreq = now, target
	rstChaseMu.Unlock()

	if err := a.cat.FlexDo(func(fc cat.FlexController) error {
		return fc.SetTXSliceFrequency(target)
	}); err != nil {
		applog.Printf("rst chase: moving the TX slice to %s failed: %v", hzText(target), err)
		return
	}
	applog.Printf("rst chase: %s marked at %s → TX slice to %s (offset %+d Hz)",
		strings.ToUpper(callsign), hzText(freqHz), hzText(target), cfg.OffsetHz)
}

// hzText renders a frequency the way an operator reads one on a dial.
func hzText(hz int64) string {
	return strconv.FormatFloat(float64(hz)/1000, 'f', 3, 64) + " kHz"
}
