package main

// What goes over the network. Sampling is local (every 60 s); the network sees four kinds
// of message, all gzip JSON posted to <url>/api/agent/v1/report:
//
//   heartbeat  every 5 min   a few numbers (CPU, memory, worst disk and its fill ETA, active rules)
//   rollup     every 15 min  per-minute averages / maxima since the last rollup, top processes,
//                            disks, network; once a day also a hash of each inventory list
//   event      at once       a local rule tripped (disk, inodes, memory, swap, load, a security
//                            signal) or an inventory list changed; at most one per minute
//   inventory  rarely        the full lists (listening ports, crontab lines, SSH key
//                            fingerprints, setuid files): after install, and when the server
//                            answers a daily hash with 205 (its copy drifted)

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync/atomic"
	"time"
)

type Header struct {
	V       int    `json:"v"`
	Type    string `json:"type"`
	T       int64  `json:"t"` // unix seconds, UTC
	Seq     int64  `json:"seq"`
	Version string `json:"version"`
}

type HeartbeatMsg struct {
	Header
	UptimeS   int64    `json:"uptime_s"`
	CPU       float64  `json:"cpu"`
	Mem       float64  `json:"mem"`
	Disk      float64  `json:"disk"` // fullest filesystem, % used
	DiskMount string   `json:"disk_mount"`
	FullInH   *float64 `json:"full_in_h,omitempty"` // soonest projected full, hours
	Active    []string `json:"active"`              // rule keys tripped now
	Spooled   int      `json:"spooled"`
}

type RollupMsg struct {
	Header
	Agent   AgentInfo         `json:"agent"`
	Host    HostInfo          `json:"host"`
	Minutes []Minute          `json:"minutes"`
	Current Current           `json:"current"`
	Active  []string          `json:"active"`
	InvHash map[string]string `json:"inv_hash,omitempty"` // once a day
	WebRoot *WebRootOut       `json:"web_root,omitempty"`
}

type SetDiff struct {
	Added   []string `json:"added,omitempty"`
	Removed []string `json:"removed,omitempty"`
}

type EventMsg struct {
	Header
	Events  []Event            `json:"events,omitempty"`
	InvDiff map[string]SetDiff `json:"inv_diff,omitempty"`
	Ports   []PortOut          `json:"ports,omitempty"` // details of newly listening ports
	Web     *WebChanges        `json:"web,omitempty"`
}

type InventoryMsg struct {
	Header
	Reason    string    `json:"reason"` // install | resync
	Inventory Inv       `json:"inventory"`
	Ports     []PortOut `json:"ports"`
}

// Inv is the inventory as last sent. Setuid is nil until the first scan.
type Inv struct {
	Ports   []string `json:"ports"`
	Cron    []string `json:"cron"`
	SSHKeys []string `json:"ssh_keys"`
	Setuid  []string `json:"setuid"`
}

func (i *Inv) fields() map[string]*[]string {
	return map[string]*[]string{"ports": &i.Ports, "cron": &i.Cron, "ssh_keys": &i.SSHKeys, "setuid": &i.Setuid}
}

// HashItems is what the server recomputes over its copy of a list: sha256 of the sorted
// items joined by newlines, first 16 bytes in hex.
func HashItems(items []string) string {
	s := sortedCopy(items)
	sum := sha256.Sum256([]byte(strings.Join(s, "\n")))
	return hex.EncodeToString(sum[:16])
}

// DiffInv compares the inventory as sent with the current one. A list that is nil in
// cur (setuid before its first scan) is left out.
func DiffInv(sent, cur Inv) map[string]SetDiff {
	out := map[string]SetDiff{}
	sf, cf := sent.fields(), cur.fields()
	for name, cp := range cf {
		if *cp == nil {
			continue
		}
		a, r := DiffSets(*sf[name], *cp)
		if len(a)+len(r) > 0 {
			out[name] = SetDiff{Added: a, Removed: r}
		}
	}
	return out
}

// invState is kept in the state directory: what the server has, as far as the agent knows.
type invState struct {
	Sent        Inv   `json:"sent"`
	InstalledAt int64 `json:"installed_at"`
	HashDay     int64 `json:"hash_day"`
}

type Outbox struct {
	c        *Collector
	persist  bool
	state    invState
	haveFull bool // the server has a full inventory from us
	resync   atomic.Bool
	seq      int64
	lastEvt  time.Time
	cur      Inv
}

func (o *Outbox) path() string { return filepath.Join(o.c.cfg.StateDir, "inventory.json") }

func NewOutbox(c *Collector, persist bool) *Outbox {
	o := &Outbox{c: c, persist: persist}
	if persist {
		if b, err := os.ReadFile(o.path()); err == nil && json.Unmarshal(b, &o.state) == nil && o.state.InstalledAt > 0 {
			o.haveFull = true
		}
	}
	return o
}

func (o *Outbox) save() {
	if !o.persist {
		return
	}
	if b, err := json.Marshal(o.state); err == nil {
		_ = writeFileAtomic(o.path(), b, 0o600)
	}
}

func (o *Outbox) header(typ string, now time.Time) Header {
	o.seq++
	return Header{V: 1, Type: typ, T: now.Unix(), Seq: o.seq, Version: o.c.version}
}

// Resync is called when the server answered 205: the next message is the full inventory.
func (o *Outbox) Resync() { o.resync.Store(true) }

// RefreshInventory reads the lists that are cheap to read (ports from the last sample,
// crontabs and authorized_keys now) and takes the latest setuid scan.
func (o *Outbox) RefreshInventory() {
	c := o.c
	pk := map[string]bool{}
	for _, p := range c.current.Ports {
		pk[p.Key()] = true
	}
	ports := make([]string, 0, len(pk))
	for k := range pk {
		ports = append(ports, k)
	}
	sort.Strings(ports)
	cron, cu := ReadCrontabs(c.host)
	keys, ku := ReadSSHKeys(c.host, c.users.All())
	c.cronUnreadable, c.sshUnreadable = cu, ku
	c.mu.Lock()
	setuid := c.setuid
	c.mu.Unlock()
	o.cur = Inv{Ports: ports, Cron: nonNil(cron), SSHKeys: nonNil(keys), Setuid: setuid}
}

func nonNil(xs []string) []string {
	if xs == nil {
		return []string{}
	}
	return xs
}

func (o *Outbox) portDetails(keys []string) []PortOut {
	want := map[string]bool{}
	for _, k := range keys {
		want[k] = true
	}
	var out []PortOut
	for _, p := range o.c.current.Ports {
		if want[p.Key()] {
			out = append(out, p)
		}
	}
	return out
}

// NeedFull: after install (no saved state) or a 205 from the server.
func (o *Outbox) NeedFull() bool { return !o.haveFull || o.resync.Load() }

func (o *Outbox) Full(now time.Time) InventoryMsg {
	reason := "install"
	if o.haveFull {
		reason = "resync"
	}
	m := InventoryMsg{Header: o.header("inventory", now), Reason: reason, Inventory: o.cur, Ports: o.portDetails(o.cur.Ports)}
	if m.Inventory.Setuid == nil && o.state.Sent.Setuid != nil {
		m.Inventory.Setuid = o.state.Sent.Setuid
	}
	o.state.Sent = m.Inventory
	if o.state.InstalledAt == 0 {
		o.state.InstalledAt = now.Unix()
	}
	o.haveFull = true
	o.resync.Store(false)
	o.save()
	return m
}

func (o *Outbox) Heartbeat(now time.Time, spooled int) HeartbeatMsg {
	cur := o.c.current
	m := HeartbeatMsg{Header: o.header("heartbeat", now), UptimeS: o.c.hostInfo.UptimeS, CPU: cur.CPU, Mem: cur.Mem.UsedPct,
		Active: o.c.rules.Active(), Spooled: spooled}
	for _, d := range cur.Disks {
		if d.Pct >= m.Disk {
			m.Disk, m.DiskMount = d.Pct, d.Mount
		}
		if d.FullInH != nil && (m.FullInH == nil || *d.FullInH < *m.FullInH) {
			v := *d.FullInH
			m.FullInH = &v
		}
	}
	return m
}

// Rollup closes the chart window: per-minute aggregates since the last rollup.
func (o *Outbox) Rollup(now time.Time) RollupMsg {
	c := o.c
	m := RollupMsg{Header: o.header("rollup", now), Host: c.hostInfo, Active: c.rules.Active()}
	m.Agent = AgentInfo{Version: c.version, Root: os.Geteuid() == 0, UID: os.Geteuid(), SampleS: int(c.cfg.SampleInterval / time.Second),
		ReportS: int(c.cfg.RollupInterval / time.Second), StartedAt: c.started.Unix(), Skipped: nonNil(c.Skipped())}
	keys := make([]int64, 0, len(c.minutes))
	for t := range c.minutes {
		keys = append(keys, t)
	}
	sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
	for _, t := range keys {
		a := c.minutes[t]
		n := float64(a.n)
		m.Minutes = append(m.Minutes, Minute{T: t, CPU: round1(a.cpu / n), CPUMax: round1(a.cpuMax), Load1: round2(a.load / n),
			Mem: round1(a.mem / n), MemMax: round1(a.memMax), Swap: round1(a.swap / n), SwapIO: round1(a.swapIO / n),
			RX: float64(int64(a.rx / n)), TX: float64(int64(a.tx / n)), Disk: round1(a.disk), N: a.n})
	}
	c.minutes = map[int64]*minuteAgg{}
	m.Current = c.current
	m.Current.Ports = nil // ports travel as inventory diffs
	c.mu.Lock()
	if c.web != nil {
		w := *c.web
		w.Changes = nil // changes travel in event messages
		m.WebRoot = &w
	}
	c.mu.Unlock()
	day := now.Unix() / 86400
	if o.haveFull && day != o.state.HashDay {
		m.InvHash = map[string]string{}
		for name, p := range o.state.Sent.fields() {
			if *p != nil {
				m.InvHash[name] = HashItems(*p)
			}
		}
		o.state.HashDay = day
		o.save()
	}
	return m
}

// Event returns the pending event message (tripped rules, inventory changes, web root
// changes), or nil when there is nothing or the last one went out less than gap ago.
func (o *Outbox) Event(now time.Time, gap time.Duration) *EventMsg {
	if now.Sub(o.lastEvt) < gap {
		return nil
	}
	c := o.c
	var diff map[string]SetDiff
	if o.haveFull {
		diff = DiffInv(o.state.Sent, o.cur)
	}
	c.mu.Lock()
	web := c.webPend
	c.mu.Unlock()
	if len(c.pending) == 0 && len(diff) == 0 && web == nil {
		return nil
	}
	m := &EventMsg{Header: o.header("event", now), Events: c.pending, Web: web}
	if len(diff) > 0 {
		m.InvDiff = diff
		if d, ok := diff["ports"]; ok {
			m.Ports = o.portDetails(d.Added)
		}
		for name, p := range o.cur.fields() {
			if *p != nil {
				*o.state.Sent.fields()[name] = *p
			}
		}
		o.save()
	}
	c.pending = nil
	c.mu.Lock()
	c.webPend = nil
	c.mu.Unlock()
	o.lastEvt = now
	return m
}

func (h Header) kind() string { return h.Type }
