// Package store — presence.
//
// Presence is a pure function over recent facts, computed each tick; decaying
// confidence over multiple weak signals, never one authoritative source;
// hysteresis (a Schmitt trigger) to stop flapping.
//
// `presenceScore` and `resolve` are deliberately pure: no I/O, no time.Now.
// The loop supplies `now` and the Signal readings (gathered under the state
// lock) as inputs — the functions here are the unit-testable core.
//
// Signals and their hand-tuned weights/τ:
//
//	(desk_active,   0.90,   8 min)  input = human at keyboard
//	(page_heartbeat,0.60,   4 min)  a surface you use is open + alive; pings ~30s
//	(wg_handshake,  0.40,  20 min)  device on tunnel; coarse, three-rooms-away
//
// Combiner is noisy-OR:  P = 1 − Π (1 − p_i)  with  p_i = weight_i · exp(-Δt_i / τ_i).
// Diminishing returns on stacking weak signals, never exceeds 1.0.
// Signals with no fact for that key drop out of the product (not zero).
//
// Hysteresis (Schmitt trigger):
//
//	ENTER (away → present):  P >= 0.55
//	EXIT  (present → away):  P <  0.30
//	cold start: away          (fail-closed; same instinct as since(key)==null)
//
// Boundaries are HAND-tuned, NOT feedback-tuned — keep presence numbers out of
// the auto-tuner or a weird week drifts you silently invisible.
package store

import (
	"math"
	"time"
)

// Signal — one presence signal with hand-tuned fresh-weight and decay τ.
type Signal struct {
	Key    string
	Weight float64
	TauMin float64
}

// PresenceSignals — the three signals. Iterate in stable order.
var PresenceSignals = []Signal{
	{Key: "desk_active", Weight: 0.90, TauMin: 8.0},
	{Key: "page_heartbeat", Weight: 0.60, TauMin: 4.0},
	{Key: "wg_handshake", Weight: 0.40, TauMin: 20.0},
}

// PresenceEnter — the ENTER threshold of the Schmitt trigger.
const PresenceEnter = 0.55

// PresenceExit — the EXIT threshold.
const PresenceExit = 0.30

// SignalProbe — the loop's reading of one signal at tick time. All that
// presence needs is, per signal key, the timestamp of the latest non-voided
// fact for that key (or nil if no fact — it then drops out of the product).
//
// The loop fills this by calling LatestFact for each Signal.Key under the
// state lock; presence itself does no I/O.
type SignalProbe struct {
	Key    string
	LastTs *time.Time // nil → no data; </> SignalProbe drops out of the product
}

// PresenceScore — pure. returns the noisy-OR combined score in [0,1).
// If every signal has no data the product collapses to 1.0 and score = 0.
// (Cold boot: away, by construction.)
func PresenceScore(now time.Time, probes []SignalProbe) float64 {
	var pAway = 1.0
	byKey := make(map[string]*time.Time, len(probes))
	for i := range probes {
		byKey[probes[i].Key] = probes[i].LastTs
	}
	for _, s := range PresenceSignals {
		last, ok := byKey[s.Key]
		if !ok || last == nil {
			continue // no data → drops out of the product
		}
		dtMin := now.Sub(*last).Minutes()
		if dtMin < 0 {
			dtMin = 0 // clock skew shouldn't gift us a p > weight
		}
		p := s.Weight * math.Exp(-dtMin/s.TauMin)
		pAway *= (1.0 - p)
	}
	return 1.0 - pAway
}

// Resolve — the Schmitt trigger. Pure.
//
//	last == present: stay present while P >= 0.30; flip to away below.
//	last == away:    stay away while P < 0.55; enter present at 0.55 or above.
//
// Cold start (no prior bucket) → away, fail-closed.
func Resolve(score float64, last Bucket) Bucket {
	if last == Present {
		if score < PresenceExit {
			return Away
		}
		return Present
	}
	// last == Away or cold-start
	if score >= PresenceEnter {
		return Present
	}
	return Away
}

// ResolveCold — convenience for the very first tick after daemon cold-start.
// presence_state has no row; we begin as Away, the fail-closed outcome.
func ResolveCold(score float64) Bucket { return Resolve(score, Away) }
