package main

// Log-aware colours in WSJT-X / JTDX's own Band Activity window (message 13),
// the way JTAlert paints them: a decode of a watchlist member, a new DXCC or a
// new band for its entity is highlighted where the operator is actually
// looking. The verdicts come from the same cluster status cache that colours
// the spot grid, so the two windows can never disagree.

import (
	"fmt"
	"strconv"
	"strings"

	"hamlog/internal/applog"
	"hamlog/internal/dxcc"
	udp "hamlog/internal/integrations/udp"
)

const (
	keyWsjtHighlight  = "udp.wsjt.highlight"
	keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode
	keyWsjtHLWorked   = "udp.wsjt.highlight_worked"
	// One key per verdict, holding "#RRGGBB". Only the BACKGROUND is stored: the
	// text colour is computed from it, so a chosen colour can never come out
	// unreadable in somebody else's window.
	keyWsjtColWatchlist = "udp.wsjt.colour.watchlist"
	keyWsjtColNewDXCC   = "udp.wsjt.colour.new_dxcc"
	keyWsjtColNewBand   = "udp.wsjt.colour.new_band"
	keyWsjtColWorked    = "udp.wsjt.colour.worked"
)

// wsjtModes are the modes a Configure message can meaningfully ask for — the
// decoder's own vocabulary. Anything else (CW, SSB, RTTY) is none of its
// business and is not sent.
var wsjtModes = map[string]bool{
	"FT8": true, "FT4": true, "JT65": true, "JT9": true,
	"MSK144": true, "Q65": true, "FST4": true, "JS8": false, // JS8Call speaks another protocol
}

// GetWsjtFollowMode reports whether spot clicks retune the decoder's mode.
func (a *App) GetWsjtFollowMode() bool {
	return a.settingOr(keyWsjtFollowMode, "1") == "1"
}

// SetWsjtFollowMode flips it.
func (a *App) SetWsjtFollowMode(on bool) {
	v := "0"
	if on {
		v = "1"
	}
	a.setSetting(keyWsjtFollowMode, v)
}

// ConfigureDecoderMode asks the connected decoders to switch mode — called by
// the frontend after a spot click has tuned the radio. A no-op for modes the
// decoder does not speak, and when the option is off or nothing is connected.
func (a *App) ConfigureDecoderMode(mode string) {
	mode = strings.ToUpper(strings.TrimSpace(mode))
	if a.udp == nil || !wsjtModes[mode] || !a.GetWsjtFollowMode() {
		return
	}
	a.udp.SendConfigureMode(mode)
}

// The DEFAULT palette. Fixed colours, not theme tokens — they are painted into
// another application's window, which has no idea what theme OpsLog wears, and
// the operator can change each of them (see WsjtHighlightColours).
var (
	hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink
	hlNewDXCC   = udp.RGB{R: 22, G: 130, B: 60}   // green
	hlNewBand   = udp.RGB{R: 226, G: 122, B: 24}  // orange
	hlWhite     = udp.RGB{R: 255, G: 255, B: 255}
	hlBlack     = udp.RGB{R: 20, G: 20, B: 20}
	// Worked already, on this band and in this mode. Grey on purpose, and the
	// only DIM colour of the four: the others say "look at this", and this one
	// says the opposite — it has to recede, not compete with them.
	hlWorked = udp.RGB{R: 75, G: 85, B: 99}
)

// WsjtHighlightColours is the operator's palette, one background per verdict.
type WsjtHighlightColours struct {
	Watchlist string `json:"watchlist"`
	NewDXCC   string `json:"new_dxcc"`
	NewBand   string `json:"new_band"`
	Worked    string `json:"worked"`
}

// GetWsjtHighlightColours returns the palette in "#RRGGBB", defaults included.
func (a *App) GetWsjtHighlightColours() WsjtHighlightColours {
	return WsjtHighlightColours{
		Watchlist: a.settingOr(keyWsjtColWatchlist, hexOfRGB(hlWatchlist)),
		NewDXCC:   a.settingOr(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC)),
		NewBand:   a.settingOr(keyWsjtColNewBand, hexOfRGB(hlNewBand)),
		Worked:    a.settingOr(keyWsjtColWorked, hexOfRGB(hlWorked)),
	}
}

// SetWsjtHighlightColours stores the palette and repaints.
//
// The repaint is the whole point of clearing: the de-duplication remembers what
// it has already told each decoder, so without this a callsign keeps yesterday's
// colour until it changes verdict — and the operator, having just picked a new
// one, sees nothing happen.
func (a *App) SetWsjtHighlightColours(c WsjtHighlightColours) {
	set := func(key, v, def string) {
		if _, ok := parseHexRGB(v); !ok {
			v = def
		}
		a.setSetting(key, strings.ToUpper(strings.TrimSpace(v)))
	}
	set(keyWsjtColWatchlist, c.Watchlist, hexOfRGB(hlWatchlist))
	set(keyWsjtColNewDXCC, c.NewDXCC, hexOfRGB(hlNewDXCC))
	set(keyWsjtColNewBand, c.NewBand, hexOfRGB(hlNewBand))
	set(keyWsjtColWorked, c.Worked, hexOfRGB(hlWorked))
	a.clearWsjtHighlights()
	applog.Printf("wsjt highlight: palette changed — repainting")
}

// colourFor reads one verdict's background and picks a legible foreground.
//
// The text colour is DERIVED, never stored: an operator choosing a dark blue
// would otherwise get black text on it in somebody else's window and conclude
// the feature is broken. Rec. 601 luma, the same rule a browser's contrast
// checker uses, with the threshold where black stops being readable.
func (a *App) colourFor(key, def string) (udp.RGB, udp.RGB) {
	bg, ok := parseHexRGB(a.settingOr(key, def))
	if !ok {
		bg, _ = parseHexRGB(def)
	}
	luma := (299*int(bg.R) + 587*int(bg.G) + 114*int(bg.B)) / 1000
	if luma < 140 {
		return bg, hlWhite
	}
	return bg, hlBlack
}

func hexOfRGB(c udp.RGB) string { return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) }

// parseHexRGB reads "#RRGGBB" (or "RRGGBB"). Anything else is refused rather
// than half-read: a colour that silently becomes black is worse than a default.
func parseHexRGB(s string) (udp.RGB, bool) {
	s = strings.TrimPrefix(strings.TrimSpace(s), "#")
	if len(s) != 6 {
		return udp.RGB{}, false
	}
	v, err := strconv.ParseUint(s, 16, 32)
	if err != nil {
		return udp.RGB{}, false
	}
	return udp.RGB{R: byte(v >> 16), G: byte(v >> 8), B: byte(v)}, true
}

// GetWsjtHighlightWorked reports whether stations already worked on this band
// and mode are greyed out as well.
//
// Its own switch, and off by default. The other three verdicts pick out a
// handful of decodes in a period; this one can match most of them on a
// well-filled log, and a screen where nearly every line is coloured has stopped
// saying anything. It is worth having only for an operator who wants the dupes
// struck out rather than the catches picked out.
func (a *App) GetWsjtHighlightWorked() bool {
	return a.settingOr(keyWsjtHLWorked, "0") == "1"
}

// SetWsjtHighlightWorked flips it, and repaints.
//
// Turning it OFF has to clear what it painted: those callsigns keep their grey
// in the decoder's window otherwise, and nothing would ever say another word
// about them — the de-duplication remembers that they were already told.
func (a *App) SetWsjtHighlightWorked(on bool) {
	v := "0"
	if on {
		v = "1"
	}
	a.setSetting(keyWsjtHLWorked, v)
	a.wsjtHLWorkedOn.Store(on)
	a.clearWsjtHighlights()
	applog.Printf("wsjt highlight: worked stations %v", on)
}

// clearWsjtHighlights wipes every instruction OpsLog installed, in every
// running decoder, and forgets what it had said.
func (a *App) clearWsjtHighlights() {
	if a.udp == nil {
		return
	}
	for _, inst := range a.udp.Instances() {
		_ = a.udp.SendClearHighlights(inst)
	}
	a.wsjtHLMu.Lock()
	a.wsjtHLSent = map[string]string{}
	a.wsjtHLMu.Unlock()
}

// GetWsjtHighlight reports whether decode highlighting is on.
func (a *App) GetWsjtHighlight() bool {
	return a.settingOr(keyWsjtHighlight, "0") == "1"
}

// SetWsjtHighlight turns decode highlighting on or off. Turning it OFF also
// clears every instruction OpsLog installed in the running applications — a
// disabled option that leaves stale colours behind looks broken, not disabled.
func (a *App) SetWsjtHighlight(on bool) {
	v := "0"
	if on {
		v = "1"
	}
	a.setSetting(keyWsjtHighlight, v)
	a.wsjtHighlightOn.Store(on)
	if !on {
		a.clearWsjtHighlights()
		applog.Printf("wsjt highlight: off — cleared in every instance")
	}
}

// maybeHighlightDecode paints one decoded callsign in the instance that heard
// it, when the option is on and the verdict is worth a colour. De-duplicated
// per instance+call+verdict: a station CQing all evening is decoded four times
// a minute, and the instruction only needs to be said once.
func (a *App) maybeHighlightDecode(instance, call, band, mode string) {
	if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" {
		return
	}
	bg, fg, verdict := a.decodeHighlightVerdict(call, band, mode)
	// The MODE is part of the key: one receiver can be handed FT8 and FT4 in
	// the same session, and the verdict on a callsign is not the same in both.
	key := instance + "|" + strings.ToUpper(call) + "|" + band + "|" + strings.ToUpper(mode)
	a.wsjtHLMu.Lock()
	if a.wsjtHLSent == nil {
		a.wsjtHLSent = map[string]string{}
	}
	if len(a.wsjtHLSent) > 4000 { // bounded; a long session just re-says a few
		a.wsjtHLSent = map[string]string{}
	}
	prev, had := a.wsjtHLSent[key]
	if had && prev == verdict {
		a.wsjtHLMu.Unlock()
		return
	}
	a.wsjtHLSent[key] = verdict
	a.wsjtHLMu.Unlock()
	if verdict == "" {
		// Was highlighted under an earlier verdict and no longer deserves it
		// (the operator just worked them): clear that one callsign.
		if had && prev != "" {
			_ = a.udp.SendHighlight(instance, call, nil, nil, false)
		}
		return
	}
	_ = a.udp.SendHighlight(instance, call, bg, fg, false)
}

// decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats
// new-band, and "already worked here" comes last of all — it is the only
// verdict that says do NOT call, so anything worth calling for outranks it.
// Anything else is "no colour", and the empty verdict doubles as the clear
// signal in maybeHighlightDecode.
func (a *App) decodeHighlightVerdict(call, band, mode string) (bg, fg *udp.RGB, verdict string) {
	c := a.clusterStatusMaps()
	// ALREADY WORKED HERE, whatever else the station is.
	//
	// Settled first because it is the one fact that cancels the others. A watch
	// list entry worked on this band and mode stayed pink for the rest of the
	// session — the list is a statement of intent, not of what is left to do, and
	// the colour that means "call this one" was being shown for a station already
	// in the log. From the operator's side there was no way to tell the two
	// apart, which is the only thing the colours are for.
	workedHere := false
	if band != "" && mode != "" && c.workedCallSlots != nil {
		m := strings.ToUpper(strings.TrimSpace(mode))
		if c.normMode != nil {
			m = c.normMode(m)
		}
		_, workedHere = c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m]
	}
	if a.watchlist != nil && !workedHere {
		if _, ok := a.watchlist.Match(call); ok {
			bgc, fgc := a.colourFor(keyWsjtColWatchlist, hexOfRGB(hlWatchlist))
			return &bgc, &fgc, "watchlist"
		}
	}
	if a.dxcc != nil {
		if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
			num := dxcc.EntityDXCC(m.Entity.Name)
			ent := c.entities[num]
			if ent == nil {
				bgc, fgc := a.colourFor(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC))
				return &bgc, &fgc, "new-dxcc"
			}
			if band != "" {
				if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
					bgc, fgc := a.colourFor(keyWsjtColNewBand, hexOfRGB(hlNewBand))
					return &bgc, &fgc, "new-band"
				}
			}
		}
	}
	// A dupe, judged by the same ledger and the same digital-mode grouping the
	// cluster uses, so the two windows cannot disagree about what "worked" means.
	// Its own option: on a well-filled log this matches most of a period, and a
	// screen where nearly every line is coloured has stopped saying anything.
	if workedHere && a.wsjtHLWorkedOn.Load() {
		bgc, fgc := a.colourFor(keyWsjtColWorked, hexOfRGB(hlWorked))
		return &bgc, &fgc, "worked"
	}
	return nil, nil, ""
}
