package main

import (
	"sort"
	"time"
)

// Local rules decide on the server when something is worth reporting at once (an "event"
// message) instead of waiting for the 15-minute rollup. Each rule has a trip condition, a
// clear condition with hysteresis and a minimum duration, so a single odd sample never trips it.
const (
	diskBad, diskGood    = 90.0, 87.0
	fullSoonH, fullOkH   = 48.0, 72.0
	memBad, memGood      = 95.0, 90.0
	swapBad, swapGood    = 500.0, 100.0 // pages swapped in + out per second
	loadFactor, loadGood = 2.0, 1.5     // x CPU cores
	memFor, swapFor      = 10 * time.Minute, 10 * time.Minute
	loadFor              = 15 * time.Minute
	diskFor              = 2 * time.Minute // two samples in a row at the default interval
	clearFor             = 5 * time.Minute
	signalGoneFor        = 5 * time.Minute
)

// Event is one tripped rule, with its evidence.
type Event struct {
	Kind   string         `json:"kind"` // disk | inode | memory | swap | load | miner | tmp_exe | deleted_exe | cpu_hog
	Key    string         `json:"key"`
	At     int64          `json:"at"`
	Detail map[string]any `json:"detail"`
}

type ruleState struct {
	badSince  time.Time // zero: not bad now
	goodSince time.Time
	active    bool
}

// Rules keeps the state of every rule key ("disk:/", "memory", "miner:xmrig:/tmp/xmrig", ...).
type Rules struct {
	st       map[string]*ruleState
	lastSeen map[string]time.Time // process signals: last sample that showed them
}

func NewRules() *Rules { return &Rules{st: map[string]*ruleState{}, lastSeen: map[string]time.Time{}} }

// step advances one key. bad/good are this sample's readings; it returns true when the key
// trips now (it was not active) and false otherwise; cleared reports the reverse.
func (r *Rules) step(key string, now time.Time, bad, good bool, need time.Duration) (tripped, cleared bool) {
	s := r.st[key]
	if s == nil {
		s = &ruleState{}
		r.st[key] = s
	}
	if bad {
		if s.badSince.IsZero() {
			s.badSince = now
		}
	} else {
		s.badSince = time.Time{}
	}
	if good {
		if s.goodSince.IsZero() {
			s.goodSince = now
		}
	} else {
		s.goodSince = time.Time{}
	}
	if !s.active && bad && now.Sub(s.badSince) >= need-time.Second {
		s.active = true
		return true, false
	}
	if s.active && good && now.Sub(s.goodSince) >= clearFor-time.Second {
		s.active = false
		return false, true
	}
	return false, false
}

// Reading is what the rules look at in one sample.
type Reading struct {
	Mem, SwapIO, SwapPct, Load1 float64
	Cores                       int
	Disks                       []DiskOut
	TopCPU, TopMem              []Proc
	Signals                     map[string]Signal // process signals seen in this sample
}

// Update runs every rule on one sample. Returns the events that tripped now and the keys
// that cleared now.
func (r *Rules) Update(now time.Time, in Reading) (events []Event, cleared []string) {
	add := func(kind, key string, detail map[string]any) {
		events = append(events, Event{Kind: kind, Key: key, At: now.Unix(), Detail: detail})
	}
	mounts := map[string]bool{}
	for _, d := range in.Disks {
		mounts[d.Mount] = true
		soon := d.FullInH != nil && *d.FullInH < fullSoonH
		good := d.Pct < diskGood && (d.FullInH == nil || *d.FullInH >= fullOkH)
		if t, c := r.step("disk:"+d.Mount, now, d.Pct >= diskBad || soon, good, diskFor); t {
			add("disk", "disk:"+d.Mount, map[string]any{"mount": d.Mount, "pct": d.Pct, "full_in_h": d.FullInH, "rate_bph": d.RateBPH,
				"total": d.Total, "used": d.Used})
		} else if c {
			cleared = append(cleared, "disk:"+d.Mount)
		}
		if t, c := r.step("inode:"+d.Mount, now, d.InodesPct >= diskBad, d.InodesPct < diskGood, diskFor); t {
			add("inode", "inode:"+d.Mount, map[string]any{"mount": d.Mount, "pct": d.InodesPct})
		} else if c {
			cleared = append(cleared, "inode:"+d.Mount)
		}
	}
	for key, s := range r.st { // a filesystem that went away ends its rules
		for _, p := range []string{"disk:", "inode:"} {
			if len(key) > len(p) && key[:len(p)] == p && !mounts[key[len(p):]] {
				if s.active {
					cleared = append(cleared, key)
				}
				delete(r.st, key)
			}
		}
	}
	top := func(ps []Proc) any {
		if len(ps) == 0 {
			return nil
		}
		return ps[0]
	}
	minutes := func(key string) int { return int(now.Sub(r.st[key].badSince).Minutes() + 0.5) }
	if t, c := r.step("memory", now, in.Mem >= memBad, in.Mem < memGood, memFor); t {
		add("memory", "memory", map[string]any{"pct": in.Mem, "minutes": minutes("memory"), "top": top(in.TopMem)})
	} else if c {
		cleared = append(cleared, "memory")
	}
	if t, c := r.step("swap", now, in.SwapIO >= swapBad, in.SwapIO < swapGood, swapFor); t {
		add("swap", "swap", map[string]any{"rate": in.SwapIO, "pct": in.SwapPct, "minutes": minutes("swap")})
	} else if c {
		cleared = append(cleared, "swap")
	}
	cores := float64(maxInt(in.Cores, 1))
	if t, c := r.step("load", now, in.Load1 > loadFactor*cores, in.Load1 < loadGood*cores, loadFor); t {
		add("load", "load", map[string]any{"load": in.Load1, "cores": in.Cores, "limit": loadFactor * cores, "minutes": minutes("load"),
			"top": top(in.TopCPU)})
	} else if c {
		cleared = append(cleared, "load")
	}
	// Process signals: active while seen, cleared after 5 minutes without them.
	keys := make([]string, 0, len(in.Signals))
	for k := range in.Signals {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		sig := in.Signals[k]
		r.lastSeen[k] = now
		s := r.st[k]
		if s == nil || !s.active {
			r.st[k] = &ruleState{active: true, badSince: now}
			add(sig.Kind, k, sig.Detail)
		}
	}
	for k, seen := range r.lastSeen {
		if _, now2 := in.Signals[k]; now2 {
			continue
		}
		if now.Sub(seen) >= signalGoneFor {
			delete(r.lastSeen, k)
			if s := r.st[k]; s != nil && s.active {
				cleared = append(cleared, k)
			}
			delete(r.st, k)
		}
	}
	sort.Strings(cleared)
	return events, cleared
}

// Active lists the keys that are tripped now.
func (r *Rules) Active() []string {
	out := []string{}
	for k, s := range r.st {
		if s.active {
			out = append(out, k)
		}
	}
	sort.Strings(out)
	return out
}

func maxInt(a, b int) int {
	if a > b {
		return a
	}
	return b
}
