// Package alerts evaluates incoming DX-cluster spots against user-defined rules
// and reports which rules fire, so the app can notify the operator (sound /
// visual / e-mail) when a wanted station is spotted — like Log4OM's Alert
// Management. Rules are persisted as JSON (global, machine-local).
package alerts

import (
	"encoding/json"
	"os"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"
)

// Rule is one alert definition. Every filter dimension is optional: an empty
// list/string matches ANY value, and the dimensions are ANDed together, so a
// rule with Countries=[France] and Bands=[20m] fires only for French stations
// on 20m. Calls / SpotterCall accept wildcards (IW3*, */P).
type Rule struct {
	ID      string `json:"id"`
	Name    string `json:"name"`
	Enabled bool   `json:"enabled"`

	// DX filters.
	Calls      []string `json:"calls,omitempty"`      // wildcard patterns on the DX call
	Countries  []string `json:"countries,omitempty"`  // DXCC entity names
	Continents []string `json:"continents,omitempty"` // AF/AN/AS/EU/NA/OC/SA

	// Band / mode filters.
	Bands []string `json:"bands,omitempty"`
	Modes []string `json:"modes,omitempty"`

	// Spotter (origin) filters.
	SpotterCall       string   `json:"spotter_call,omitempty"` // wildcard
	SpotterContinents []string `json:"spotter_continents,omitempty"`
	SpotterCountries  []string `json:"spotter_countries,omitempty"`

	// Actions.
	Sound  bool `json:"sound"`
	Visual bool `json:"visual"`
	Email  bool `json:"email"`

	// Throttling: minutes to wait before re-alerting the same callsign. 0 = only
	// once per session, -1 = always, >0 = that many minutes.
	AgainAfterMin int `json:"again_after_min"`

	// Skip a spot whose DX call is already worked on this band+mode.
	SkipWorked bool `json:"skip_worked"`
}

// Spot is the subset of a cluster spot the engine matches against.
type Spot struct {
	DXCall           string
	Band             string
	Mode             string // inferred (see InferMode)
	Country          string // DXCC entity name
	Continent        string
	Spotter          string
	SpotterCountry   string
	SpotterContinent string
}

// Store persists the rule set as a JSON file.
type Store struct {
	mu    sync.Mutex
	path  string
	rules []Rule
	// last fired time per (ruleID|call), for the AgainAfter throttle.
	lastFired map[string]time.Time
}

// Open loads the store (empty when the file is absent).
func Open(path string) (*Store, error) {
	s := &Store{path: path, lastFired: map[string]time.Time{}}
	b, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return s, nil
		}
		return nil, err
	}
	if len(b) > 0 {
		_ = json.Unmarshal(b, &s.rules) // corrupt file → start empty
	}
	return s, nil
}

func (s *Store) save() error {
	b, err := json.MarshalIndent(s.rules, "", "  ")
	if err != nil {
		return err
	}
	tmp := s.path + ".tmp"
	if err := os.WriteFile(tmp, b, 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, s.path)
}

// List returns a copy of all rules.
func (s *Store) List() []Rule {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]Rule, len(s.rules))
	copy(out, s.rules)
	return out
}

func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) }

// Save upserts a rule (creating an id when empty) and returns it.
func (s *Store) Save(r Rule) (Rule, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if strings.TrimSpace(r.ID) == "" {
		r.ID = newID()
		s.rules = append(s.rules, r)
	} else {
		found := false
		for i := range s.rules {
			if s.rules[i].ID == r.ID {
				s.rules[i] = r
				found = true
				break
			}
		}
		if !found {
			s.rules = append(s.rules, r)
		}
	}
	return r, s.save()
}

// Delete removes a rule by id.
func (s *Store) Delete(id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.rules {
		if s.rules[i].ID == id {
			s.rules = append(s.rules[:i], s.rules[i+1:]...)
			return s.save()
		}
	}
	return nil
}

// Match is a rule that fired for a spot (returned by Evaluate).
type Match struct {
	Rule Rule
	Spot Spot
}

// Evaluate returns every enabled rule that matches the spot AND isn't currently
// throttled. It records the fire time for matched rules so the AgainAfter window
// is honoured. workedFn (may be nil) reports whether the DX call is already
// worked on this band+mode — used by rules with SkipWorked.
func (s *Store) Evaluate(sp Spot, now time.Time, workedFn func(call, band, mode string) bool) []Match {
	s.mu.Lock()
	defer s.mu.Unlock()
	var out []Match
	for _, r := range s.rules {
		if !r.Enabled || !ruleMatches(r, sp) {
			continue
		}
		if r.SkipWorked && workedFn != nil && workedFn(sp.DXCall, sp.Band, sp.Mode) {
			continue
		}
		key := r.ID + "|" + strings.ToUpper(sp.DXCall)
		if r.AgainAfterMin >= 0 {
			last, seen := s.lastFired[key]
			if seen {
				if r.AgainAfterMin == 0 {
					continue // once per session
				}
				if now.Sub(last) < time.Duration(r.AgainAfterMin)*time.Minute {
					continue
				}
			}
		}
		s.lastFired[key] = now
		out = append(out, Match{Rule: r, Spot: sp})
	}
	return out
}

// ruleMatches reports whether every specified filter dimension matches the spot.
func ruleMatches(r Rule, sp Spot) bool {
	if len(r.Calls) > 0 && !anyWildcard(r.Calls, sp.DXCall) {
		return false
	}
	if len(r.Countries) > 0 && !containsFold(r.Countries, sp.Country) {
		return false
	}
	if len(r.Continents) > 0 && !containsFold(r.Continents, sp.Continent) {
		return false
	}
	if len(r.Bands) > 0 && !containsFold(r.Bands, sp.Band) {
		return false
	}
	if len(r.Modes) > 0 && !containsFold(r.Modes, sp.Mode) {
		return false
	}
	if strings.TrimSpace(r.SpotterCall) != "" && !matchWildcard(r.SpotterCall, sp.Spotter) {
		return false
	}
	if len(r.SpotterContinents) > 0 && !containsFold(r.SpotterContinents, sp.SpotterContinent) {
		return false
	}
	if len(r.SpotterCountries) > 0 && !containsFold(r.SpotterCountries, sp.SpotterCountry) {
		return false
	}
	return true
}

func containsFold(list []string, v string) bool {
	v = strings.TrimSpace(v)
	if v == "" {
		return false
	}
	for _, x := range list {
		if strings.EqualFold(strings.TrimSpace(x), v) {
			return true
		}
	}
	return false
}

func anyWildcard(patterns []string, v string) bool {
	for _, p := range patterns {
		if matchWildcard(p, v) {
			return true
		}
	}
	return false
}

// matchWildcard does a case-insensitive full-string match where '*' matches any
// run of characters and '?' matches one (e.g. "IW3*", "*/P", "F?BPO").
func matchWildcard(pattern, v string) bool {
	pattern = strings.TrimSpace(pattern)
	v = strings.TrimSpace(v)
	if pattern == "" {
		return true
	}
	var b strings.Builder
	b.WriteString("(?i)^")
	for _, r := range pattern {
		switch r {
		case '*':
			b.WriteString(".*")
		case '?':
			b.WriteString(".")
		default:
			b.WriteString(regexp.QuoteMeta(string(r)))
		}
	}
	b.WriteString("$")
	re, err := regexp.Compile(b.String())
	if err != nil {
		return strings.EqualFold(pattern, v)
	}
	return re.MatchString(v)
}

// InferMode works out a spot's mode from its comment, then from the band plan.
//
// Cluster lines carry no mode field, so this is all there is — and it feeds the
// alert rules, the FlexRadio panadapter colours and the spot the radio is told
// about. It used to end with a bare "return SSB" for anything its handful of CW
// ranges did not cover, which is how a 30 m FT8 spot at 10131.5 raised an alert
// announcing SSB while the cluster list beside it said DATA.
//
// The comment still wins when it names a mode: a spotter who wrote FT8 knows
// better than any table. Below that is the shared band plan (bandplan.go), and
// below THAT is nothing — an empty mode, which callers read as "unknown" rather
// than as a claim.
func InferMode(comment string, freqHz int64) string {
	c := strings.ToUpper(comment)
	switch {
	case strings.Contains(c, "FT8"):
		return "FT8"
	case strings.Contains(c, "FT4"):
		return "FT4"
	case strings.Contains(c, "RTTY"):
		return "RTTY"
	case strings.Contains(c, "PSK"):
		return "PSK"
	case strings.Contains(c, "JS8"):
		return "JS8"
	// WPM is as good as the word CW: nothing else is reported in words per
	// minute, and RBN spots carry it on every line.
	case strings.Contains(c, "CW"), strings.Contains(c, "WPM"):
		return "CW"
	case strings.Contains(c, "SSB"), strings.Contains(c, "USB"),
		strings.Contains(c, "LSB"), strings.Contains(c, "PH"):
		return "SSB"
	}
	return modeFromFrequency(freqHz)
}
