package store

import (
	"math"
	"testing"
	"time"
)

func refTime() time.Time {
	return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
}

func TestPresenceScoreColdBoot(t *testing.T) {
	// No probes at all. Product collapses to 1.0; score = 0. Fail-closed.
	got := PresenceScore(refTime(), nil)
	if math.Abs(got-0.0) > 1e-9 {
		t.Fatalf("cold boot: want 0, got %f", got)
	}
	if b := ResolveCold(got); b != Away {
		t.Fatalf("cold boot: want Away, got %s", b)
	}
}

func TestPresenceScoreAllSignalsDroppedOnNoFact(t *testing.T) {
	// Every probe present but LastTs nil → drops out → score 0.
	probes := []SignalProbe{
		{Key: "desk_active", LastTs: nil},
		{Key: "page_heartbeat", LastTs: nil},
		{Key: "wg_handshake", LastTs: nil},
	}
	if got := PresenceScore(refTime(), probes); got != 0.0 {
		t.Fatalf("all-nil probes: want 0, got %f", got)
	}
}

func TestPresenceScoreAtDeskTyping(t *testing.T) {
	// Fresh (<1s) desk_active + heartbeat → very high. Spec table: ~0.90.
	now := refTime()
	justNow := now.Add(-1 * time.Second)
	probes := []SignalProbe{
		{Key: "desk_active", LastTs: &justNow},
		{Key: "page_heartbeat", LastTs: &justNow},
	}
	got := PresenceScore(now, probes)
	if got < 0.85 {
		t.Fatalf("at desk typing: want >= 0.85, got %f", got)
	}
	if b := Resolve(got, Away); b != Present {
		t.Fatalf("away→present at P=%f: want Present, got %s", got, b)
	}
}

func TestPresenceScoreFreshWGAloneCannotEnter(t *testing.T) {
	// A lone fresh wg (weight 0.40) is below ENTER (0.55). Cannot declare present.
	now := refTime()
	justNow := now.Add(-1 * time.Second)
	probes := []SignalProbe{{Key: "wg_handshake", LastTs: &justNow}}
	got := PresenceScore(now, probes)
	if got > PresenceEnter {
		t.Fatalf("fresh wg alone: want <= ENTER, got %f", got)
	}
	if b := Resolve(got, Away); b != Away {
		t.Fatalf("wg alone enters present: want Away, got %s (P=%f)", b, got)
	}
}

func TestPresenceDeskOnlyDecaysToAway(t *testing.T) {
	// Desk-only, zero input for ~9min → <0.30 → Away. Spec table row 3.
	now := refTime()
	deskThen := now.Add(-9 * time.Minute)
	probes := []SignalProbe{{Key: "desk_active", LastTs: &deskThen}}
	got := PresenceScore(now, probes)
	if got >= PresenceExit {
		t.Fatalf("9min-stale desk-only: want < EXIT(%f), got %f", PresenceExit, got)
	}
	if b := Resolve(got, Present); b != Away {
		t.Fatalf("present→away at P=%f (9min stale desk): want Away, got %s", got, b)
	}
}

func TestPresenceHysteresisHoldsWhileDecaying(t *testing.T) {
	// Already present. WG alone decayed—but a fresh-ish wg_holding(0.40) while
	// present must HOLD present even though it could not ENTER present. Band.
	now := refTime()
	wgThen := now.Add(-1 * time.Minute) // dt=1min; p ≈ 0.40 * exp(-1/20) ≈ 0.381
	probes := []SignalProbe{{Key: "wg_handshake", LastTs: &wgThen}}
	got := PresenceScore(now, probes)
	// Sanity: above EXIT, below ENTER — sitting in the hold band.
	if got <= PresenceExit || got >= PresenceEnter {
		t.Fatalf("decaying wg in hold band: want (%f, %f), got %f", PresenceExit, PresenceEnter, got)
	}
	if b := Resolve(got, Present); b != Present {
		t.Fatalf("hold during decay: want Present, got %s (P=%f, last=Present)", b, got)
	}
	// But the exact same reading from Away must NOT enter.
	if b := Resolve(got, Away); b != Away {
		t.Fatalf("from Away the same P must not enter: want Away, got %s (P=%f)", b, got)
	}
}

func TestPresenceCouchPhoneOpen(t *testing.T) {
	// Couch, phone page open, no desk → ~0.60, just present (≥0.55).
	now := refTime()
	justNow := now.Add(-1 * time.Second)
	probes := []SignalProbe{{Key: "page_heartbeat", LastTs: &justNow}}
	got := PresenceScore(now, probes)
	// weight 0.60, decay≈0 → near 0.60. Should ENTER from away.
	if got < PresenceEnter {
		t.Fatalf("couch+phone: want >= ENTER(%f), got %f", PresenceEnter, got)
	}
	if b := Resolve(got, Away); b != Present {
		t.Fatalf("couch+phone enters: want Present, got %s (P=%f)", b, got)
	}
}

func TestPresenceUnequalWeightsSumDiminishingReturns(t *testing.T) {
	// Noisy-OR: stacking adds diminishing returns; never exceeds 1.0.
	// At t=0 all three fresh: P = 1 - (0.1)(0.4)(0.6) = 1 - 0.024 = 0.976.
	now := refTime()
	t0 := now
	probes := []SignalProbe{
		{Key: "desk_active", LastTs: &t0},
		{Key: "page_heartbeat", LastTs: &t0},
		{Key: "wg_handshake", LastTs: &t0},
	}
	got := PresenceScore(now, probes)
	if got >= 1.0 {
		t.Fatalf("stacked fresh: want < 1.0, got %f", got)
	}
	if math.Abs(got-0.976) > 1e-3 {
		t.Fatalf("stacked fresh: want ≈ 0.976, got %f", got)
	}
}

func TestNegativeClockSkewClampsToFresh(t *testing.T) {
	// last is in the future relative to now (clock skew). Should clamp dt to 0
	// rather than gifting a p > weight via exp(-negative/τ) > 1.
	now := refTime()
	future := now.Add(5 * time.Minute)
	probes := []SignalProbe{{Key: "desk_active", LastTs: &future}}
	got := PresenceScore(now, probes)
	if got > 0.90+1e-9 {
		t.Fatalf("clock skew clamps: want <= weight(0.90), got %f", got)
	}
}
