package store

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"path/filepath"
	"testing"
	"time"

	"github.com/kami/maven/internal/calendar"
)

func newTestStore(t *testing.T) *Store {
	t.Helper()
	dir := t.TempDir()
	path := filepath.Join(dir, "maven_test.db")
	s, err := Open(context.Background(), path)
	if err != nil {
		t.Fatalf("Open: %v", err)
	}
	t.Cleanup(func() { _ = s.Close() })
	return s
}

func TestWriteAndLatestFact(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	// ts is stored as unix millis; round to ms to match the roundtrip.
	now := time.Now().UTC().Truncate(time.Millisecond)
	if _, err := s.SetValue(ctx, KindSelf, "water", "tap:water", map[string]int{"ml": 250}, now); err != nil {
		t.Fatalf("SetValue: %v", err)
	}
	f, err := s.LatestFact(ctx, "water")
	if err != nil {
		t.Fatalf("LatestFact: %v", err)
	}
	if f.Key != "water" || f.Source != "tap:water" || f.Confidence != 1.0 {
		t.Fatalf("got %+v", f)
	}
	var v map[string]int
	if err := json.Unmarshal([]byte(f.Value), &v); err != nil || v["ml"] != 250 {
		t.Fatalf("value roundtrip: %v (%s)", err, f.Value)
	}
	if !f.Ts.Equal(now) {
		t.Fatalf("ts roundtrip: want %s got %s", now, f.Ts)
	}
}

func TestAppendOnlySupersedeNotOverwrite(t *testing.T) {
	// Two facts for the same key: LatestFact returns the newer one, the older
	// row is still there (append-only audit trail).
	s := newTestStore(t)
	ctx := context.Background()
	t1 := time.Now().UTC().Add(-5 * time.Minute)
	t2 := time.Now().UTC()
	if _, err := s.SetValue(ctx, KindSelf, "meal", "tap:meal", "pasta", t1); err != nil {
		t.Fatal(err)
	}
	if _, err := s.SetValue(ctx, KindSelf, "meal", "tap:meal", "salad", t2); err != nil {
		t.Fatal(err)
	}
	f, err := s.LatestFact(ctx, "meal")
	if err != nil {
		t.Fatal(err)
	}
	if f.Value != `"salad"` {
		t.Fatalf("latest value: want salad, got %s", f.Value)
	}
	// audit trail still has both rows
	var n int
	if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM facts WHERE key = 'meal'").Scan(&n); err != nil {
		t.Fatal(err)
	}
	if n != 2 {
		t.Fatalf("append-only: want 2 rows, got %d", n)
	}
}

func TestCorrectValueVoidsAndSupersedes(t *testing.T) {
	// User corrects a bad fact → latest row voids the previous; LatestFact
	// now returns the corrected one; voids_id points back at the old row.
	s := newTestStore(t)
	ctx := context.Background()
	old := time.Now().UTC().Add(-2 * time.Minute)
	if _, err := s.SetValue(ctx, KindSelf, "sleep", "tap:sleep", "8h", old); err != nil {
		t.Fatal(err)
	}
	now := time.Now().UTC()
	newID, err := s.CorrectValue(ctx, "sleep", "feedback", "6h", now)
	if err != nil {
		t.Fatalf("CorrectValue: %v", err)
	}
	f, err := s.LatestFact(ctx, "sleep")
	if err != nil {
		t.Fatal(err)
	}
	if f.ID != newID {
		t.Fatalf("latest should be corrected row: want id=%d got=%d", newID, f.ID)
	}
	if !f.VoidsID.Valid || f.VoidsID.Int64 == 0 {
		t.Fatalf("corrected row should void the old one: %+v", f.VoidsID)
	}
	// old row should NOT come back from LatestFact
	if f.Value != `"6h"` {
		t.Fatalf("value: want 6h got %s", f.Value)
	}
	// audit trail: 2 rows; one of them voids the other
	var voidedCount int
	if err := s.db.QueryRowContext(ctx,
		"SELECT COUNT(*) FROM facts WHERE key='sleep' AND voids_id IS NOT NULL").Scan(&voidedCount); err != nil {
		t.Fatal(err)
	}
	if voidedCount != 1 {
		t.Fatalf("exactly one voiding row, got %d", voidedCount)
	}
}

func TestSinceNoFactReturnsErrNoFact(t *testing.T) {
	// silence on no-data = "shuts up when uncertain"
	s := newTestStore(t)
	ctx := context.Background()
	if _, err := s.Since(ctx, "never_observed", time.Now().UTC()); !errors.Is(err, ErrNoFact) {
		t.Fatalf("Since on missing key: want ErrNoFact, got %v", err)
	}
}

func TestConfidenceBounds(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	for _, c := range []float64{0.0, -0.1, 1.5} {
		if _, err := s.WriteFact(ctx, time.Now().UTC(), KindSelf, "x", "v", "tap", c, sql.NullInt64{}); !errors.Is(err, ErrConfidence) {
			t.Fatalf("confidence %f: want ErrConfidence, got %v", c, err)
		}
	}
}

func TestProvenanceScopedLookup(t *testing.T) {
	// A compensating fact from a non-authoritative source should NOT override the
	// authoritative one when the rule uses LatestFactBySource.
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now().UTC()
	if _, err := s.SetValue(ctx, KindEnv, "service_nginx", "poll:healthcheck", "down", now); err != nil {
		t.Fatal(err)
	}
	if _, err := s.SetValue(ctx, KindEnv, "service_nginx", "ambient", "down", now.Add(time.Second)); err != nil {
		t.Fatal(err)
	}
	// unscoped latest = ambient (newer)
	if f, _ := s.LatestFact(ctx, "service_nginx"); f.Source != "ambient" {
		t.Fatalf("LatestFact: want ambient, got %s", f.Source)
	}
	// source-scoped = poll:healthcheck
	f, err := s.LatestFactBySource(ctx, "service_nginx", "poll:healthcheck")
	if err != nil || f.Source != "poll:healthcheck" {
		t.Fatalf("LatestFactBySource: want poll:healthcheck, got %+v / %v", f, err)
	}
	// missing source → ErrNoFact (a compromised poller can't forge a trigger)
	if _, err := s.LatestFactBySource(ctx, "service_nginx", "poll:bogus"); !errors.Is(err, ErrNoFact) {
		t.Fatalf("bogus source: want ErrNoFact, got %v", err)
	}
}

func TestRemindersRelativeResolvedAtCapture(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	// capture path (router) converts "in 4h" → absolute. store just takes fire_ts.
	fire := time.Now().UTC().Add(4 * time.Hour)
	id, err := s.CreateReminder(ctx, fire, `{"text":"wake me"}`, "")
	if err != nil {
		t.Fatal(err)
	}
	// not due yet
	if due, err := s.DueReminders(ctx, time.Now().UTC()); err != nil || len(due) != 0 {
		t.Fatalf("before fire: want 0 due, got %d (%v)", len(due), err)
	}
	// due once past fire_ts
	if due, err := s.DueReminders(ctx, fire.Add(time.Second)); err != nil || len(due) != 1 || due[0].ID != id {
		t.Fatalf("after fire: want 1 due (%d), got %d (%v)", id, len(due), err)
	}
	// mark fired → not due again (fires once)
	if err := s.MarkReminder(ctx, id, "fired"); err != nil {
		t.Fatalf("MarkReminder: %v", err)
	}
	if due, err := s.DueReminders(ctx, fire.Add(2*time.Second)); err != nil || len(due) != 0 {
		t.Fatalf("after fired: want 0 due, got %d", len(due))
	}
	// can't fire again
	if err := s.MarkReminder(ctx, id, "fired"); !errors.Is(err, ErrReminderState) {
		t.Fatalf("re-fire: want ErrReminderState, got %v", err)
	}
}

func TestRecurringReminder(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC)

	// Create a daily recurring reminder at 9:00
	fire := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
	id, err := s.CreateReminder(ctx, fire, `{"text":"daily standup"}`, "0 9 * * *")
	if err != nil {
		t.Fatal(err)
	}

	// Not due yet (at 8:00, next_fire_ts = 9:00)
	if due, err := s.DueReminders(ctx, now); err != nil || len(due) != 0 {
		t.Fatalf("before fire: want 0 due, got %d (%v)", len(due), err)
	}

	// Due at 9:00
	due, err := s.DueReminders(ctx, fire.Add(time.Second))
	if err != nil || len(due) != 1 || due[0].ID != id {
		t.Fatalf("after fire: want 1 due (%d), got %d (%v)", id, len(due), err)
	}
	if due[0].Cron != "0 9 * * *" {
		t.Fatalf("cron: want %q, got %q", "0 9 * * *", due[0].Cron)
	}

	// Reschedule: next fire should be tomorrow 9:00
	if err := s.RescheduleReminder(ctx, id, fire); err != nil {
		t.Fatalf("RescheduleReminder: %v", err)
	}
	tomorrow := fire.Add(24 * time.Hour)
	due, err = s.DueReminders(ctx, tomorrow.Add(time.Second))
	if err != nil || len(due) != 1 || due[0].ID != id {
		t.Fatalf("after reschedule: want 1 due (%d), got %d (%v)", id, len(due), err)
	}

	// Mark non-recurring reminder → ErrReminderState
	_, err = s.CreateReminder(ctx, fire, `{"text":"one-shot"}`, "")
	if err != nil {
		t.Fatal(err)
	}
	// The last id is id+1
	if err := s.RescheduleReminder(ctx, id+1, fire); !errors.Is(err, ErrReminderState) {
		t.Fatalf("reschedule one-shot: want ErrReminderState, got %v", err)
	}
}

func TestNudgeOnceAndFeedbackOutcomes(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now().UTC()
	id, err := s.RecordNudge(ctx, "water", "voice", "drink some water", now)
	if err != nil {
		t.Fatal(err)
	}
	// pending state: not yet in outcomes
	if os, _ := s.RecentOutcomes(ctx, "water", 5); len(os) != 0 {
		t.Fatalf("pending should not count as outcome: got %v", os)
	}
	// resolve once → ok
	if err := s.ResolveNudge(ctx, id, "acted", now.Add(time.Minute)); err != nil {
		t.Fatalf("ResolveNudge: %v", err)
	}
	// re-resolve rejected — feedback signal must not be silently corruptable
	if err := s.ResolveNudge(ctx, id, "ignored", now.Add(2*time.Minute)); !errors.Is(err, ErrNudgeOutcome) {
		t.Fatalf("re-resolve: want ErrNudgeOutcome, got %v", err)
	}
	// outcomes feed back: 1 acted in last N
	if os, _ := s.RecentOutcomes(ctx, "water", 5); len(os) != 1 || os[0] != "acted" {
		t.Fatalf("outcomes: want [acted], got %v", os)
	}
}

func TestUnackedTelegramRules(t *testing.T) {
	// the dispatcher's RepeatUnacked reads this to know which sev4 telegram
	// sends are still un-acked. telegram is the sev4-away channel by routing
	// construction, so channel+outcome is the full filter.
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now().UTC()

	// none → empty (not nil-iff-not-set is fine; empty slice is the contract)
	if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 0 {
		t.Fatalf("cold: want [] err=nil, got %v %v", got, err)
	}

	// a pending telegram nudge → its rule appears.
	if _, err := s.RecordNudge(ctx, "service_down", "telegram", "homesrv down", now); err != nil {
		t.Fatal(err)
	}
	got, err := s.UnackedTelegramRules(ctx)
	if err != nil || len(got) != 1 || got[0] != "service_down" {
		t.Fatalf("after send: want [service_down], got %v %v", got, err)
	}

	// a pending nudge on a different channel (voice) must NOT appear — the
	// repeat-til-ack path is telegram-only.
	if _, err := s.RecordNudge(ctx, "water", "voice", "drink water", now); err != nil {
		t.Fatal(err)
	}
	if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 1 || got[0] != "service_down" {
		t.Fatalf("voice must not appear: want [service_down], got %v %v", got, err)
	}

	// a second pending telegram nudge for a different rule → both appear,
	// sorted by rule name (deterministic for the daemon).
	if _, err := s.RecordNudge(ctx, "disk_full", "telegram", "disk 99%", now); err != nil {
		t.Fatal(err)
	}
	if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 2 || got[0] != "disk_full" || got[1] != "service_down" {
		t.Fatalf("two rules: want [disk_full service_down], got %v %v", got, err)
	}

	// resolving one (the user acked disk_full) → only the other remains.
	// RecordNudge returned disk_full's id; re-query to get it here.
	id, err := s.LastNudge(ctx, "disk_full")
	if err != nil {
		t.Fatalf("LastNudge disk_full: %v", err)
	}
	if err := s.ResolveNudge(ctx, id.ID, "acted", now.Add(time.Minute)); err != nil {
		t.Fatalf("ResolveNudge: %v", err)
	}
	if got, err := s.UnackedTelegramRules(ctx); err != nil || len(got) != 1 || got[0] != "service_down" {
		t.Fatalf("after ack disk_full: want [service_down], got %v %v", got, err)
	}
}

func TestPresenceStateSingletonRoundtrip(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	// cold start → away, 0
	b, score, _, err := s.LoadPresenceState(ctx)
	if err != nil || b != Away || score != 0 {
		t.Fatalf("cold: want Away/0, got %s/%f (%v)", b, score, err)
	}
	// save → reload
	now := time.Now().UTC()
	if err := s.SavePresenceState(ctx, Present, 0.83, now); err != nil {
		t.Fatal(err)
	}
	if b, score, _, err := s.LoadPresenceState(ctx); err != nil || b != Present || score != 0.83 {
		t.Fatalf("after save: want Present/0.83, got %s/%f (%v)", b, score, err)
	}
}

func TestCalendarEvents(t *testing.T) {
	store := newTestStore(t)
	defer store.Close()

	ctx := context.Background()
	now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)

	// Write facts for different dates
	store.WriteFact(ctx, time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC), KindSelf, "calendar_event_20260706_Morning-standup", `"Morning standup @ 10:00-10:30"`, "poll:caldav", 1.0, sql.NullInt64{})
	store.WriteFact(ctx, time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC), KindSelf, "calendar_event_20260706_Lunch", `"Lunch @ 12:00-13:00"`, "poll:caldav", 1.0, sql.NullInt64{})
	store.WriteFact(ctx, time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC), KindSelf, "calendar_event_20260707_Doctor", `"Doctor @ 14:00-15:00"`, "poll:caldav", 1.0, sql.NullInt64{})
	store.WriteFact(ctx, time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC), KindSelf, "calendar_event_20260705_Yesterday", `"Yesterday thing"`, "poll:caldav", 1.0, sql.NullInt64{})

	// Query July 6
	events, err := store.CalendarEvents(ctx, now.Truncate(24*time.Hour), now.Truncate(24*time.Hour).Add(24*time.Hour))
	if err != nil {
		t.Fatalf("CalendarEvents: %v", err)
	}
	if len(events) != 2 {
		t.Fatalf("expected 2 events on July 6, got %d", len(events))
	}

	// Query July 7
	t7 := time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC)
	events, err = store.CalendarEvents(ctx, t7, t7.Add(24*time.Hour))
	if err != nil {
		t.Fatalf("CalendarEvents: %v", err)
	}
	if len(events) != 1 {
		t.Fatalf("expected 1 event on July 7, got %d", len(events))
	}

	// Query date with no events
	t8 := time.Date(2026, 7, 8, 0, 0, 0, 0, time.UTC)
	events, err = store.CalendarEvents(ctx, t8, t8.Add(24*time.Hour))
	if err != nil {
		t.Fatalf("CalendarEvents: %v", err)
	}
	if len(events) != 0 {
		t.Fatalf("expected 0 events on July 8, got %d", len(events))
	}
}

// A rescheduled meeting keeps its key and appends a row. The query must return
// the current value, not the history: reciting both told the owner he had two
// standups when one had been moved.
func TestCalendarEventsReturnsOneRowPerEvent(t *testing.T) {
	store := newTestStore(t)
	defer store.Close()

	ctx := context.Background()
	day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
	const key = "calendar_event_20260803_Standup"

	store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, key,
		`"Standup @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
	store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, key,
		`"Standup @ 16:00-16:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
	// The notification relay guessed at the same meeting. A calendar read is
	// better evidence, so the hedged row must not displace it.
	store.WriteFact(ctx, day.Add(17*time.Hour), KindEnv, key,
		`"Standup @ 17:00-17:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{})

	events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
	if err != nil {
		t.Fatalf("CalendarEvents: %v", err)
	}
	if len(events) != 1 {
		t.Fatalf("got %d rows, want the current one only: %+v", len(events), events)
	}
	if events[0].Value != `"Standup @ 16:00-16:30"` {
		t.Errorf("value = %q, want the latest calendar read", events[0].Value)
	}
}

// A voided calendar fact is gone, not history to recite.
func TestCalendarEventsSkipsVoidedRows(t *testing.T) {
	store := newTestStore(t)
	defer store.Close()

	ctx := context.Background()
	day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
	id, err := store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Cancelled",
		`"Cancelled @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
	if err != nil {
		t.Fatalf("WriteFact: %v", err)
	}
	if _, err := store.WriteFact(ctx, day.Add(15*time.Hour), KindEnv, "calendar_event_20260803_Cancelled",
		`"Cancelled @ 14:00-14:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{Int64: id, Valid: true}); err != nil {
		t.Fatalf("WriteFact void: %v", err)
	}

	events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
	if err != nil {
		t.Fatalf("CalendarEvents: %v", err)
	}
	for _, e := range events {
		if e.ID == id {
			t.Fatalf("voided row %d came back: %+v", id, e)
		}
	}
}

// The work calendar arrives as relayed phone notifications, not a CalDAV read
// (Vikunja #126). Those events belong in the same day's answer, and their
// provenance has to survive the query so the caller can hedge them.
func TestCalendarEventsIncludesAmbientSource(t *testing.T) {
	store := newTestStore(t)
	defer store.Close()

	ctx := context.Background()
	day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)

	store.WriteFact(ctx, day.Add(10*time.Hour), KindEnv, "calendar_event_20260803_Aaa-personal",
		`"Aaa personal @ 10:00-10:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
	store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Bbb-work",
		`"Bbb work @ 14:00-14:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{})
	// A fact that merely looks like one must still be excluded by source.
	store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, "calendar_event_20260803_Ccc-forged",
		`"Ccc forged @ 16:00-16:30"`, "tap:voice", 1.0, sql.NullInt64{})

	events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
	if err != nil {
		t.Fatalf("CalendarEvents: %v", err)
	}
	if len(events) != 2 {
		t.Fatalf("got %d events, want the personal and the ambient one: %+v", len(events), events)
	}
	bySource := map[string]Fact{}
	for _, e := range events {
		bySource[e.Source] = e
	}
	if _, ok := bySource[calendar.SourcePersonal]; !ok {
		t.Error("the personal CalDAV event is missing")
	}
	amb, ok := bySource[calendar.SourceAmbient]
	if !ok {
		t.Fatal("the ambient work event is missing")
	}
	if amb.Confidence >= 1.0 {
		t.Errorf("ambient confidence = %v, must stay below a calendar read's", amb.Confidence)
	}
	if _, ok := bySource["tap:voice"]; ok {
		t.Error("a non-calendar source must not be read as a calendar event")
	}
}

// TestRecentActiveFactsByKind — the behaviour profile's window. Env rows must
// not spend it, and a retracted row must not be counted as something he did.
func TestRecentActiveFactsByKind(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now().UTC().Truncate(time.Millisecond)

	// A noisy env writer, the way mavpoll writes handshakes.
	for i := 0; i < 50; i++ {
		if _, err := s.WriteFact(ctx, now.Add(-time.Duration(i)*time.Minute), KindEnv,
			"wg_handshake", "1", "poll:wireguard", 1.0, sql.NullInt64{}); err != nil {
			t.Fatalf("write env fact: %v", err)
		}
	}
	if _, err := s.WriteFact(ctx, now.Add(-2*time.Hour), KindSelf, "workout", "done", "tap:voice", 1.0, sql.NullInt64{}); err != nil {
		t.Fatalf("write self fact: %v", err)
	}
	if _, err := s.WriteFact(ctx, now.Add(-3*time.Hour), KindSelf, "walk", "done", "tap:voice", 1.0, sql.NullInt64{}); err != nil {
		t.Fatalf("write self fact: %v", err)
	}
	if _, _, err := s.VoidLatestFact(ctx, "walk", "tap:voice", now.Add(-time.Hour)); err != nil {
		t.Fatalf("VoidLatestFact: %v", err)
	}

	got, err := s.RecentActiveFactsByKind(ctx, KindSelf, 10)
	if err != nil {
		t.Fatalf("RecentActiveFactsByKind: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("got %d facts, want 1 (workout): %+v", len(got), got)
	}
	if got[0].Key != "workout" {
		t.Errorf("key = %q, want workout", got[0].Key)
	}

	// The window is spent on self rows only: a limit smaller than the env
	// traffic still returns the taps.
	got, err = s.RecentActiveFactsByKind(ctx, KindSelf, 1)
	if err != nil {
		t.Fatalf("RecentActiveFactsByKind: %v", err)
	}
	if len(got) != 1 || got[0].Key != "workout" {
		t.Fatalf("got %+v, want the newest self fact", got)
	}
}
