package main

// Panadapter spot colours, per status.
//
// A FlexRadio draws every OpsLog spot in one colour today, which throws away the
// only thing worth knowing at a glance on a waterfall: whether the station is
// worth stopping for. The radio's own API has always taken a text colour AND a
// background colour per spot — see internal/cat/flex.go — so the information can
// be carried without a single extra pixel of screen.
//
// The statuses are the ones the cluster already computes (see spotEntityStatus),
// plus the three orthogonal markers an operator chases separately.

import (
	"encoding/json"
	"strings"
)

// keySpotColors holds the per-status palette.
const keySpotColors = "flex.spot_colors"

// SpotColor is one status's pair. Both are #AARRGGBB — alpha FIRST, which is
// SmartSDR's order, not the web's. An empty background leaves the radio's own.
type SpotColor struct {
	Text string `json:"text"`
	Bg   string `json:"bg,omitempty"`
	// Hide keeps this status OFF the panadapter entirely.
	//
	// Expressed as "hide" and not "send" so the zero value means SEND: every
	// palette stored before this existed has to keep behaving as it did, and a
	// missing field must never be read as "the operator turned this off".
	//
	// A colour and a switch rather than one control: the operator who stops
	// drawing already-worked stations today may want them back tomorrow, and
	// their colour should still be there when they do.
	Hide bool `json:"hide,omitempty"`
}

// SpotColors is the whole palette, keyed by the status names the cluster uses.
//
// Stored as a map rather than a struct with nine fields so a status added later
// (a new award marker, say) needs no migration: an unknown key is ignored and a
// missing one falls back to the default below.
type SpotColors struct {
	// Enabled off sends no colour at all, which is the behaviour before this
	// existed: every spot in the radio's default orange. Kept as a real switch so
	// turning it off is a genuine revert rather than "some other palette".
	Enabled bool                 `json:"enabled"`
	Colors  map[string]SpotColor `json:"colors"`
}

// spotColorOrder is the palette's canonical order — most wanted first, then the
// orthogonal markers, then the two "nothing here" cases. The settings panel
// renders it in this order, and it is the order the resolver tries.
var spotColorOrder = []string{
	"new", "new-band-mode", "new-band", "new-mode", "new-slot",
	"new-pota", "new-county", "new-pfx",
	"my-call", "worked", "none",
}

// defaultSpotColors is the shipped palette.
//
// Text colours are opaque and bright, backgrounds are the same hue at a fifth of
// the alpha: a waterfall is dark and busy, and a solid plate behind the callsign
// hides the very signal the operator is looking at. The scale runs red → amber →
// blue for "never had it" → "missing a piece" → "already yours".
var defaultSpotColors = map[string]SpotColor{
	"new":           {Text: "#FFFF3B30", Bg: "#40FF3B30"}, // entity never worked — the one you stop for
	"new-band-mode": {Text: "#FFFF6B22", Bg: "#40FF6B22"}, // neither band nor mode: nearly as good
	"new-band":      {Text: "#FFFFCC00", Bg: "#40FFCC00"},
	"new-mode":      {Text: "#FFFFA500", Bg: "#40FFA500"},
	"new-slot":      {Text: "#FF5AC8FA", Bg: "#405AC8FA"},
	"new-pota":      {Text: "#FF34C759", Bg: "#4034C759"},
	"new-county":    {Text: "#FF30D158", Bg: "#4030D158"},
	"new-pfx":       {Text: "#FFAF52DE", Bg: "#40AF52DE"},
	"my-call":       {Text: "#FFFF2D55", Bg: "#60FF2D55"}, // someone spotted YOU
	"worked":        {Text: "#FF9CA3AF", Bg: "#209CA3AF"}, // already in the log — present, quiet
	"none":          {Text: "#FF9CA3AF", Bg: ""},          // status unresolved
}

// hexARGB accepts #AARRGGBB and nothing else. The Flex refuses anything shorter
// with a command error the operator never sees, so a half-typed value must not
// reach the radio — it is dropped here and the default is used instead.
func hexARGB(s string) bool {
	s = strings.TrimSpace(s)
	if len(s) != 9 || s[0] != '#' {
		return false
	}
	for i := 1; i < len(s); i++ {
		c := s[i]
		if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') {
			return false
		}
	}
	return true
}

// normSpotColors drops anything that is not a valid colour, so the stored
// palette can never make the radio reject a spot.
func normSpotColors(s SpotColors) SpotColors {
	out := SpotColors{Enabled: s.Enabled, Colors: map[string]SpotColor{}}
	known := map[string]bool{}
	for _, k := range spotColorOrder {
		known[k] = true
	}
	for k, v := range s.Colors {
		if !known[k] {
			continue
		}
		c := SpotColor{Hide: v.Hide}
		if hexARGB(v.Text) {
			c.Text = strings.ToUpper(strings.TrimSpace(v.Text))
		}
		if hexARGB(v.Bg) {
			c.Bg = strings.ToUpper(strings.TrimSpace(v.Bg))
		}
		// Kept when it carries ANY decision — a status with no colours but "hide"
		// set is a real instruction, and dropping it would silently switch that
		// status back on.
		if c.Text != "" || c.Bg != "" || c.Hide {
			out.Colors[k] = c
		}
	}
	return out
}

// GetSpotColors returns the palette, filled with the defaults for every status
// the operator has not overridden — so the panel always has a colour to show and
// the resolver never has to decide what "unset" looks like.
func (a *App) GetSpotColors() SpotColors {
	out := SpotColors{Enabled: true, Colors: map[string]SpotColor{}}
	if raw := a.settingOr(keySpotColors, ""); raw != "" {
		var stored SpotColors
		if err := json.Unmarshal([]byte(raw), &stored); err == nil {
			stored = normSpotColors(stored)
			out.Enabled = stored.Enabled
			out.Colors = stored.Colors
		}
	}
	for _, k := range spotColorOrder {
		if _, ok := out.Colors[k]; !ok {
			out.Colors[k] = defaultSpotColors[k]
		}
	}
	return out
}

// SaveSpotColors persists the palette.
func (a *App) SaveSpotColors(s SpotColors) error {
	b, err := json.Marshal(normSpotColors(s))
	if err != nil {
		return err
	}
	a.setSetting(keySpotColors, string(b))
	a.spotColorsMu.Lock()
	a.spotColorsCache = nil // re-read on the next spot
	a.spotColorsMu.Unlock()
	return nil
}

// ResetSpotColors puts the shipped palette back.
func (a *App) ResetSpotColors() SpotColors {
	a.setSetting(keySpotColors, "")
	a.spotColorsMu.Lock()
	a.spotColorsCache = nil
	a.spotColorsMu.Unlock()
	return a.GetSpotColors()
}

// spotColorFor picks the pair for one spot status.
//
// Cached, because this runs on EVERY spot from every cluster — a busy evening is
// several a second, and re-reading and re-parsing the settings JSON for each one
// would put a database round trip in the middle of the cluster read loop.
func (a *App) spotColorFor(status string) SpotColor {
	a.spotColorsMu.Lock()
	if a.spotColorsCache == nil {
		c := a.GetSpotColors()
		a.spotColorsCache = &c
	}
	p := a.spotColorsCache
	a.spotColorsMu.Unlock()
	if !p.Enabled {
		return SpotColor{}
	}
	if c, ok := p.Colors[status]; ok {
		return c
	}
	return p.Colors["none"]
}

// spotStatusTag is the short label appended to a panadapter spot's comment.
//
// The colour says WHAT a spot is only to someone who has learnt the palette; the
// word says it to everyone, including the operator who set the colours a month
// ago. Both, then — the same choice DXHunter makes, whose spots read
// "10 dB 18 WPM CQ [AC0C] [United States] [NEW CTY]".
//
// Short on purpose: the comment shares a panadapter with the signals, and a
// spot label that runs over its neighbours hides the very thing it describes.
// "worked" and an unresolved entity get nothing at all — there is no news in
// either, and the absence of a tag is itself the answer.
func spotStatusTag(status string) string {
	switch status {
	case "new":
		return "New DXCC"
	case "new-band-mode":
		return "New B&M"
	case "new-band":
		return "New Band"
	case "new-mode":
		return "New Mode"
	case "new-slot":
		return "New Slot"
	case "new-pota":
		return "New POTA"
	case "new-county":
		return "New Cnty"
	case "new-pfx":
		return "New Pfx"
	case "my-call":
		return "Me"
	case "worked":
		return "Worked"
	}
	return ""
}

// spotComment builds what the panadapter shows under a spot:
//
//	CQ up 2 [F4BPO] [Franz Josef Land] [New Slot]
//
// the cluster's own text, then WHO spotted it, then the entity, then what it is
// worth. Same order and same shape as DXHunter, whose users read these at a
// glance and should not have to learn a second convention.
//
// # The spaces are not spaces
//
// SmartSDR's command line separates parameters ON SPACES, so a comment written
// with ordinary ones arrives at the radio as "CQup2[F4BPO]" — every word run
// together, which is exactly how ours looked. Non-breaking spaces (U+00A0) pass
// through the parser untouched and render as spaces on the panadapter. This is
// the trick DXHunter uses, and there is no other way to get a readable comment
// through that protocol.
func spotComment(comment, spotter, country, status string) string {
	var b strings.Builder
	add := func(s string) {
		s = strings.TrimSpace(s)
		if s == "" {
			return
		}
		if b.Len() > 0 {
			b.WriteString(" ")
		}
		b.WriteString(s)
	}
	// The radio has the last word on length: a comment past roughly sixty
	// characters comes back from SmartSDR cut mid-word — one was echoed as
	// "…[European Russia" with the closing bracket and the status gone. So the
	// parts are added in order of what an operator would keep, and the cluster's
	// own words — the longest and the least specific — are the ones trimmed.
	// The cluster's own text arrives padded: RBN lines are column-aligned, so a
	// comment reads "FT8    5 dB  LN14    CQ" with runs of spaces holding the
	// columns apart. Those runs became runs of NON-BREAKING spaces, which the
	// panadapter faithfully rendered as a gap wide enough to push the rest of the
	// comment off the screen. Columns mean nothing once the text is out of its
	// table: one space between words.
	comment = strings.Join(strings.Fields(comment), " ")
	tag := bracket(spotStatusTag(status))
	spot := bracket(spotter)
	cty := bracket(country)
	room := panCommentMax - len(tag) - len(spot) - len(cty) - 3 // 3 separators
	add(trimTo(comment, room))
	add(spot)
	add(cty)
	add(tag)
	// Every space, including the ones already inside the cluster's comment.
	return strings.ReplaceAll(b.String(), " ", "\u00A0")
}

// panCommentMax is what SmartSDR keeps of a spot comment. Measured against a
// real radio's own status echo, with a margin: the cost of being wrong is a
// comment cut mid-word, and nothing in the protocol reports the limit.
const panCommentMax = 58

// trimTo shortens s to at most n characters, on a word boundary when it can.
// A negative or tiny budget yields nothing at all — the brackets that follow
// matter more than a two-letter fragment of the cluster's text.
func trimTo(s string, n int) string {
	s = strings.TrimSpace(s)
	if n < 4 {
		return ""
	}
	if len(s) <= n {
		return s
	}
	cut := s[:n]
	if i := strings.LastIndex(cut, " "); i > n/2 {
		cut = cut[:i]
	}
	return strings.TrimSpace(cut)
}

// bracket wraps a non-empty value in square brackets.
func bracket(s string) string {
	s = strings.TrimSpace(s)
	if s == "" {
		return ""
	}
	return "[" + s + "]"
}

// spotHidden reports whether this status should stay off the panadapter.
//
// Read from the same cached palette as the colours — see spotColorFor — because
// it is consulted on every spot from every cluster.
// spotPriority ranks a spot for SmartSDR's own tie-breaker, 1 (highest) to 5.
//
// The panadapter has finite room: spots close in frequency are stacked behind a
// "+" and only one of them is drawn. The radio chooses by priority — so an
// entity never worked was disappearing behind three stations already in the
// log, simply because OpsLog never said which was worth the space.
//
// The tiers are the operator's own: the entity never worked first, then the
// pieces of one already worked (band, mode, slot), then the reference hunts
// (POTA, SOTA, county, prefix), and everything else last. My own callsign rides
// at the top with the first — it is how a multi-op sees where it already is.
func spotPriority(status string) int {
	switch status {
	case "new", "my-call":
		return 1
	case "new-band-mode", "new-band", "new-mode", "new-slot":
		return 2
	case "new-pota", "new-sota", "new-county", "new-pfx":
		return 3
	default:
		return 5
	}
}

func (a *App) spotHidden(status string) bool {
	a.spotColorsMu.Lock()
	if a.spotColorsCache == nil {
		c := a.GetSpotColors()
		a.spotColorsCache = &c
	}
	p := a.spotColorsCache
	a.spotColorsMu.Unlock()
	if c, ok := p.Colors[status]; ok {
		return c.Hide
	}
	return false
}
