package main

// Watchlist bindings — the DXHunter watchlist concept as an OpsLog tab.
// The store lives in internal/watchlist (global watchlist.json, DXHunter's own
// schema); this file is the Wails boundary plus the two places the list meets
// the rest of the app: the spot stream (MarkSeen + alert) and the logbook (the
// worked-today answer contest entries are judged by).

import (
	"context"
	"fmt"
	"net/http"
	"strings"
	"time"

	"hamlog/internal/applog"
	"hamlog/internal/qso"
	"hamlog/internal/watchlist"

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

// The auto-contest pattern, DXHunter's contest_prefix: while non-empty, any
// spotted callsign CONTAINING it is added to the watchlist as a contest entry
// by itself — a special-event fleet (HB9WWA, DL0WWA, F4WWA…) is collected as
// it appears instead of typed in one by one. Held in an atomic so the spot
// pipeline never touches the settings store per spot.
func (a *App) GetWatchlistContestPattern() string {
	if v := a.watchPattern.Load(); v != nil {
		return v.(string)
	}
	return ""
}

// SetWatchlistContestPattern stores the pattern (global, like the list itself).
// Emptying it stops the auto-add; entries already collected stay — deleting an
// operator's list because a setting changed is DXHunter behaviour this port
// deliberately drops.
func (a *App) SetWatchlistContestPattern(p string) {
	p = strings.ToUpper(strings.TrimSpace(p))
	a.watchPattern.Store(p)
	a.setSettingGlobal(keyWatchlistContestPattern, p)
}

// The named contest callsigns: the other half of the auto-add, and the half the
// pattern cannot express.
//
// A special-event fleet is usually a common string in the callsign — WWA in
// TM29WWA, HB9WWA, F4WWA/P — and the pattern collects those on sight. But an
// entry list is not a naming convention: R7W can be part of the same event and
// share nothing with it, and no pattern will ever catch that station without
// catching half the band with it. So the two work together: the pattern for the
// family, this list for everybody else.
//
// Stored GLOBALLY, like the pattern and the list itself — an event is worth
// hunting whichever station profile is on.
func (a *App) GetWatchlistContestCalls() string {
	return a.settingOr(keyWatchlistContestCalls, "")
}

// SetWatchlistContestCalls stores the list as typed and caches the parsed set.
//
// The RAW text is what is stored: the operator's line breaks and their order
// are how the list is read back a week later, and rewriting it into a
// normalised single line loses the only structure it has.
func (a *App) SetWatchlistContestCalls(list string) {
	a.setSettingGlobal(keyWatchlistContestCalls, list)
	a.watchCalls.Store(parseContestCalls(list))
	applog.Printf("watchlist: %d named contest callsigns", len(parseContestCalls(list)))
}

// parseContestCalls splits the box into a set. One per line is what is asked
// for, and commas, semicolons and spaces are accepted too: a list pasted from
// an announcement arrives in whatever shape the announcement used.
func parseContestCalls(list string) map[string]struct{} {
	out := map[string]struct{}{}
	for _, tok := range strings.FieldsFunc(strings.ToUpper(list), func(r rune) bool {
		return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
	}) {
		if tok = strings.TrimSpace(tok); tok != "" {
			out[tok] = struct{}{}
		}
	}
	return out
}

// isNamedContestCall answers the hot path from the cached set.
func (a *App) isNamedContestCall(call string) bool {
	v := a.watchCalls.Load()
	if v == nil {
		return false
	}
	set, ok := v.(map[string]struct{})
	if !ok {
		return false
	}
	_, found := set[strings.ToUpper(strings.TrimSpace(call))]
	return found
}

// WatchlistEntries returns the list for the tab.
func (a *App) WatchlistEntries() []watchlist.Entry {
	if a.watchlist == nil {
		return nil
	}
	return a.watchlist.Entries()
}

// WatchlistAdd adds a callsign or prefix; contest entries are judged per UTC day.
func (a *App) WatchlistAdd(callsign string, contest bool) error {
	if a.watchlist == nil {
		return fmt.Errorf("watchlist not initialized")
	}
	if err := a.watchlist.Add(callsign, contest); err != nil {
		return err
	}
	a.notifyWatchlist(callsign, true)
	return nil
}

// WatchlistRemove deletes an entry.
func (a *App) WatchlistRemove(callsign string) error {
	if a.watchlist == nil {
		return fmt.Errorf("watchlist not initialized")
	}
	if err := a.watchlist.Remove(callsign); err != nil {
		return err
	}
	a.notifyWatchlist(callsign, false)
	return nil
}

// notifyWatchlist announces a membership change to the UI.
//
// Emitted from the BINDINGS rather than from the watchlist panel, because a
// call can now be added from three places (the panel, the cluster's menu, the
// DXpeditions tab) and an operator who added one from the cluster saw nothing
// at all — the confirmation lived inside a panel they were not looking at.
func (a *App) notifyWatchlist(callsign string, added bool) {
	if a.ctx == nil {
		return
	}
	wruntime.EventsEmit(a.ctx, "watchlist:changed", map[string]any{
		"call":  strings.ToUpper(strings.TrimSpace(callsign)),
		"added": added,
	})
}

// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
func (a *App) WatchlistSetNotify(callsign string, on bool) error {
	if a.watchlist == nil {
		return fmt.Errorf("watchlist not initialized")
	}
	return a.watchlist.SetNotify(callsign, on)
}

// WatchlistSetContest flips the per-entry contest rule.
func (a *App) WatchlistSetContest(callsign string, on bool) error {
	if a.watchlist == nil {
		return fmt.Errorf("watchlist not initialized")
	}
	return a.watchlist.SetContest(callsign, on)
}

// WatchlistSlotQuery asks whether one spot's slot is worked — against the whole
// log for a normal entry, against TODAY (UTC) for a contest one.
type WatchlistSlotQuery struct {
	Call    string `json:"call"`
	Band    string `json:"band"`
	Mode    string `json:"mode"`
	Contest bool   `json:"contest"`
}

// WatchlistWorkedSlots answers a batch of slot questions for the tab.
//
// Normal entries read the CLUSTER's own slot set — the same cached
// WorkedCallSlotKeys index that colours the grid — so the watchlist can never
// contradict the cluster about the same spot. That index normalises the mode
// exactly as the operator configured (digital grouping on → FT4 counts as FT8's
// class; off → exact mode), and the first version of this ignored that: it
// asked the alerts' raw-mode index with a CLASS name, matched nothing, and
// showed a fully-worked DXpedition as all Needed.
//
// Contest entries read TODAY's contacts instead — the midnight-UTC reset is
// the query's date bound, nothing stored, nothing to reset — normalised through
// the same function so the two answers use one grammar.
func (a *App) WatchlistWorkedSlots(queries []WatchlistSlotQuery) []bool {
	out := make([]bool, len(queries))
	if a.qso == nil || len(queries) == 0 {
		return out
	}
	idx := a.clusterStatusMaps()
	norm := func(m string) string {
		m = strings.ToUpper(strings.TrimSpace(m))
		if idx.normMode != nil {
			m = idx.normMode(m)
		}
		return m
	}
	slotKey := func(call, band, mode string) string {
		up := strings.ToUpper(strings.TrimSpace(call))
		b := strings.ToLower(strings.TrimSpace(band))
		if m := norm(mode); m != "" {
			return up + "|" + b + "|" + m
		}
		return up + "|" + b
	}
	// A spot whose mode the band plan could only call "DATA" (an F/H DXpedition
	// off the standard dials, a comment with no mode) cannot be matched exactly:
	// "DATA" is in nobody's log. Such a spot is judged at digital-CLASS grain —
	// worked if ANY digital mode of that call is logged on that band. Claiming
	// NEW MODE because the label differs from FT8 was the reported bug.
	generic := func(m string) bool {
		switch strings.ToUpper(strings.TrimSpace(m)) {
		case "", "DATA", "DIG", "DIGI", "DIGITAL":
			return true
		}
		return false
	}
	digKey := func(call, band string) string {
		return strings.ToUpper(strings.TrimSpace(call)) + "|" + strings.ToLower(strings.TrimSpace(band)) + "|DIG"
	}
	needToday := false
	for _, q := range queries {
		if q.Contest {
			needToday = true
			break
		}
	}
	today := map[string]bool{}
	if needToday {
		midnight := time.Now().UTC().Truncate(24 * time.Hour)
		rows, err := a.qso.SlotsSince(a.ctx, midnight)
		if err == nil {
			for _, r := range rows {
				today[slotKey(r.Callsign, r.Band, r.Mode)] = true
				today[digKey(r.Callsign, r.Band)] = qso.ModeClass(r.Mode) == "DIG" || today[digKey(r.Callsign, r.Band)]
			}
		}
	}
	for i, q := range queries {
		if q.Contest {
			if generic(q.Mode) {
				out[i] = today[digKey(q.Call, q.Band)]
			} else {
				out[i] = today[slotKey(q.Call, q.Band, q.Mode)]
			}
		} else if generic(q.Mode) {
			if idx.workedCallSlotsDig != nil {
				_, out[i] = idx.workedCallSlotsDig[digKey(q.Call, q.Band)]
			}
		} else if idx.workedCallSlots != nil {
			_, out[i] = idx.workedCallSlots[slotKey(q.Call, q.Band, q.Mode)]
		}
	}
	return out
}

// watchSpot runs one live spot through the watchlist: last-seen bookkeeping and
// the alert, through the SAME event the alert rules fire — the frontend already
// knows how to toast and sound it, and a second notification path would be a
// second thing to misconfigure.
func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int64) {
	if a.watchlist == nil {
		return
	}
	entry, notify, ok := a.watchlist.MarkSeen(dxCall)
	if !ok {
		// Not watched yet — the contest pattern or the named list may claim it.
		// Contains, not prefix, for the pattern: the event string sits anywhere
		// in those calls (HB9WWA, F4WWA/P). The named list is exact, and is what
		// catches the entries whose callsign says nothing about the event.
		p := a.GetWatchlistContestPattern()
		byPattern := p != "" && strings.Contains(strings.ToUpper(dxCall), p)
		byName := a.isNamedContestCall(dxCall)
		if byPattern || byName {
			if err := a.watchlist.Add(dxCall, true); err == nil {
				why := fmt.Sprintf("contest pattern %q", p)
				if byName {
					why = "named in the contest list"
				}
				applog.Printf("watchlist: auto-added %s (%s)", dxCall, why)
				entry, notify, ok = a.watchlist.MarkSeen(dxCall)
				if a.ctx != nil {
					wruntime.EventsEmit(a.ctx, "watchlist:changed")
				}
			}
		}
	}
	if !ok || !notify || a.ctx == nil {
		return
	}
	// Already worked? Then nothing to announce. Judged HERE, before the sound —
	// the report was an alert ringing for a slot the tab showed as Worked a
	// moment later, because the frontend's verdict arrives on a debounce while
	// the alert used to fire on the raw spot. Same verdict as the tab: exact
	// slot for named modes, digital class for a generic DATA spot, today-only
	// for a contest entry.
	if e, found := a.watchlist.Get(entry); found {
		if a.WatchlistWorkedSlots([]WatchlistSlotQuery{{Call: dxCall, Band: band, Mode: mode, Contest: e.IsContest}})[0] {
			return
		}
	}
	// Throttled per entry: a DXpedition lights up every skimmer on the planet,
	// and forty alerts a minute for one station is a alarm nobody keeps on.
	a.watchAlertMu.Lock()
	last := a.watchAlertAt[entry]
	now := time.Now()
	if now.Sub(last) < 2*time.Minute {
		a.watchAlertMu.Unlock()
		return
	}
	a.watchAlertAt[entry] = now
	a.watchAlertMu.Unlock()
	wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{
		"rule":    "Watchlist " + entry,
		"call":    strings.ToUpper(strings.TrimSpace(dxCall)),
		"band":    band,
		"mode":    mode,
		"freq_hz": freqHz,
		"country": country,
		"comment": comment,
		"sound":   true,
		"visual":  true,
	})
}

// startWatchlistClubLog begins the expedition enrichment: every entry's
// ClubLog block (expedition flag, OQRS, LiveStream, QSO totals and 24 h rate)
// is refreshed on a rolling schedule — hourly for expeditions, six-hourly for
// the rest, DXHunter's own cadence. OpsLog's application API key is used, so
// there is nothing to configure.
//
// Two at a time with a breath between requests: the key is shared by every
// OpsLog install, and a hundred-entry watchlist must read as a trickle at
// ClubLog's end, not a burst.
func (a *App) startWatchlistClubLog() {
	if a.watchlist == nil {
		return
	}
	go func() {
		client := &http.Client{Timeout: 30 * time.Second}
		// Let the app finish starting before the first network pass.
		time.Sleep(15 * time.Second)
		for {
			stale := a.watchlist.StaleEntries(1*time.Hour, 6*time.Hour)
			changed := false
			for _, call := range stale {
				// The pattern's BASE is what ClubLog knows: VK9* has no log.
				base := strings.TrimSuffix(call, "*")
				if base == "" {
					continue
				}
				ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
				d, err := watchlist.FetchWatch(ctx, client, clublogAppAPIKey, base)
				cancel()
				if err != nil {
					applog.Printf("watchlist clublog: %s: %v", base, err)
					continue
				}
				if a.watchlist.ApplyClubLog(call, d) {
					changed = true
					if d.IsExpedition {
						applog.Printf("watchlist clublog: %s is a DXpedition (%d QSOs, %d/24h, OQRS=%v)",
							base, func() int {
								if d.ClubLogInfo != nil {
									return d.ClubLogInfo.TotalQSOs
								}
								return 0
							}(), d.QSOs24h(), d.HasOQRS)
					}
				}
				time.Sleep(400 * time.Millisecond)
			}
			if changed && a.ctx != nil {
				wruntime.EventsEmit(a.ctx, "watchlist:changed")
			}
			time.Sleep(10 * time.Minute)
		}
	}()
}
