// Package watchlist is the DXHunter watchlist, transplanted: a list of
// callsigns (or prefixes) the operator is hunting, matched against the live
// spot stream, with per-entry state that survives restarts.
//
// The JSON file is DXHunter's OWN schema, field for field — including the
// ClubLog expedition block this phase does not fill yet. Deliberate: the
// operator asked to carry their watchlist.json across, and a file that
// round-trips unchanged is the whole of "same format". The file is GLOBAL
// (dataDir, beside nets.json), not per profile: a DXpedition worth hunting is
// worth hunting whichever station is on.
//
// The CONTEST idea is per entry, not a global mode as in DXHunter: an entry
// marked contest is judged against the CURRENT UTC DAY — at midnight UTC
// yesterday's contacts stop counting and every slot reads "work today" again.
// Nothing resets and nothing is stored for it: the day boundary lives in the
// query, which is why it cannot drift or need a timer.
package watchlist

import (
	"encoding/json"
	"fmt"
	"os"
	"sort"
	"strings"
	"sync"
	"time"
)

// Entry is one watched callsign. JSON tags match DXHunter's watchlist.json
// exactly — see the package comment before renaming anything.
type Entry struct {
	Callsign    string    `json:"callsign"`
	LastSeen    time.Time `json:"lastSeen"`
	LastSeenStr string    `json:"lastSeenStr"`
	AddedAt     time.Time `json:"addedAt"`
	SpotCount   int       `json:"spotCount"`
	IsContest   bool      `json:"isContest"`
	Notify      bool      `json:"notify"`
	// ClubLog expedition enrichment — phase 2. Carried so a DXHunter file
	// round-trips; not filled by OpsLog yet.
	IsExpedition      bool      `json:"isExpedition"`
	ClubLogQSOs24h    int       `json:"clubLogQSOs24h"`
	ClubLogTotalQSOs  int       `json:"clubLogTotalQSOs"`
	ClubLogHasOQRS    bool      `json:"clubLogHasOQRS"`
	ClubLogLiveStream bool      `json:"clubLogLiveStream"`
	ClubLogUpdatedAt  time.Time `json:"clubLogUpdatedAt,omitempty"`
}

// Store owns the file. All methods are safe for concurrent use; writes are
// debounced so a burst of MarkSeen calls costs one disk write.
type Store struct {
	mu      sync.RWMutex
	entries map[string]*Entry
	path    string

	saveMu    sync.Mutex
	saveTimer *time.Timer
	ioMu      sync.Mutex
}

func New(path string) *Store {
	s := &Store{entries: map[string]*Entry{}, path: path}
	s.load()
	return s
}

func (s *Store) load() {
	data, err := os.ReadFile(s.path)
	if err != nil {
		return // absent on first run — created on first save
	}
	var list []Entry
	if err := json.Unmarshal(data, &list); err != nil {
		return // a corrupt file must not take the app down; it is left for repair
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range list {
		e := list[i]
		e.Callsign = strings.ToUpper(strings.TrimSpace(e.Callsign))
		if e.Callsign == "" {
			continue
		}
		s.entries[e.Callsign] = &e
	}
}

// Entries returns a snapshot, watched-first ordering left to the UI.
func (s *Store) Entries() []Entry {
	s.mu.RLock()
	defer s.mu.RUnlock()
	out := make([]Entry, 0, len(s.entries))
	for _, e := range s.entries {
		c := *e
		c.LastSeenStr = lastSeenLabel(c.LastSeen)
		out = append(out, c)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Callsign < out[j].Callsign })
	return out
}

// Add creates an entry; contest marks it as re-workable every UTC day. A
// trailing * makes the entry a prefix (VK9* catches every VK9…); anywhere else
// the star is refused rather than silently matching nothing.
func (s *Store) Add(callsign string, contest bool) error {
	call := strings.ToUpper(strings.TrimSpace(callsign))
	if call == "" {
		return fmt.Errorf("callsign required")
	}
	if i := strings.Index(call, "*"); i >= 0 && i != len(call)-1 {
		return fmt.Errorf("* is only allowed at the end (VK9*)")
	}
	if call == "*" {
		return fmt.Errorf("a bare * would match every spot")
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.entries[call]; ok {
		return fmt.Errorf("%s is already on the watchlist", call)
	}
	s.entries[call] = &Entry{Callsign: call, AddedAt: time.Now(), IsContest: contest}
	s.scheduleSave()
	return nil
}

// Remove deletes an entry.
func (s *Store) Remove(callsign string) error {
	call := strings.ToUpper(strings.TrimSpace(callsign))
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.entries[call]; !ok {
		return fmt.Errorf("%s is not on the watchlist", call)
	}
	delete(s.entries, call)
	s.scheduleSave()
	return nil
}

// SetNotify arms or disarms the alert for one entry.
func (s *Store) SetNotify(callsign string, on bool) error {
	return s.patch(callsign, func(e *Entry) { e.Notify = on })
}

// SetContest flips the per-entry contest rule.
func (s *Store) SetContest(callsign string, on bool) error {
	return s.patch(callsign, func(e *Entry) { e.IsContest = on })
}

func (s *Store) patch(callsign string, fn func(*Entry)) error {
	call := strings.ToUpper(strings.TrimSpace(callsign))
	s.mu.Lock()
	defer s.mu.Unlock()
	e, ok := s.entries[call]
	if !ok {
		return fmt.Errorf("%s is not on the watchlist", call)
	}
	fn(e)
	s.scheduleSave()
	return nil
}

// Match returns the entry a spotted callsign belongs to, or "".
//
// EXACT unless the entry says otherwise: N8W matches only N8W, and it takes
// N8W* to catch N8WCR. The first version prefix-matched everything, DXHunter
// style, and a three-letter special-event call swallowed every longer call
// sharing its start — N8W lit up for N8WCR, which is a different station
// entirely. The operator writes the star when they MEAN a family (VK9*, an
// expedition's portable forms via RI0SP*); a bare entry means that call.
func (s *Store) Match(callsign string) (string, bool) {
	call := strings.ToUpper(strings.TrimSpace(callsign))
	if call == "" {
		return "", false
	}
	s.mu.RLock()
	defer s.mu.RUnlock()
	for pattern := range s.entries {
		if PatternMatches(pattern, call) {
			return pattern, true
		}
	}
	return "", false
}

// PatternMatches reports whether one watchlist pattern covers a callsign:
// exact equality, or — with a trailing * — a prefix.
func PatternMatches(pattern, call string) bool {
	if p, ok := strings.CutSuffix(pattern, "*"); ok {
		return p != "" && strings.HasPrefix(call, p)
	}
	return call == pattern
}

// MarkSeen records a spot against the matching entry and reports whether the
// entry wants an alert.
func (s *Store) MarkSeen(callsign string) (entry string, notify bool, ok bool) {
	pattern, found := s.Match(callsign)
	if !found {
		return "", false, false
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	e := s.entries[pattern]
	if e == nil {
		return "", false, false
	}
	e.LastSeen = time.Now()
	e.SpotCount++
	s.scheduleSave()
	return e.Callsign, e.Notify, true
}

// scheduleSave debounces the write: a burst of spots costs one file write two
// seconds after the last of them. Callers hold s.mu.
func (s *Store) scheduleSave() {
	s.saveMu.Lock()
	defer s.saveMu.Unlock()
	if s.saveTimer != nil {
		s.saveTimer.Stop()
	}
	s.saveTimer = time.AfterFunc(2*time.Second, func() { s.persist() })
}

func (s *Store) persist() {
	list := s.Entries()
	data, err := json.MarshalIndent(list, "", "  ")
	if err != nil {
		return
	}
	s.ioMu.Lock()
	defer s.ioMu.Unlock()
	_ = os.WriteFile(s.path, data, 0o644)
}

// Flush writes now — for shutdown, where the two-second debounce would lose
// the last edits.
func (s *Store) Flush() { s.persist() }

// lastSeenLabel is DXHunter's "Just now / 5m ago / 3h ago / 2d ago" string,
// computed at read time so it never goes stale in the file.
func lastSeenLabel(t time.Time) string {
	if t.IsZero() {
		return "Never"
	}
	d := time.Since(t)
	switch {
	case d < time.Minute:
		return "Just now"
	case d < time.Hour:
		return fmt.Sprintf("%dm ago", int(d.Minutes()))
	case d < 24*time.Hour:
		return fmt.Sprintf("%dh ago", int(d.Hours()))
	default:
		return fmt.Sprintf("%dd ago", int(d.Hours()/24))
	}
}

// Get returns one entry by its exact name — the alert path needs its contest
// flag after MarkSeen named it.
func (s *Store) Get(callsign string) (Entry, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	e, ok := s.entries[strings.ToUpper(strings.TrimSpace(callsign))]
	if !ok {
		return Entry{}, false
	}
	return *e, true
}
