package calendar

import (
	"os"
	"testing"
	"time"
)

// A bare clock reading in a notification is read in the daemon's zone, so every
// test here needs a known one. UTC+4 is the deploy's (Europe/Samara) and it is
// the offset the 18:30 bug was measured at, so a regression shows up as four
// hours rather than as nothing at all on a UTC runner.
func TestMain(m *testing.M) {
	time.Local = time.FixedZone("+04", 4*3600)
	os.Exit(m.Run())
}

func TestEventFromNotification(t *testing.T) {
	posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)

	tests := []struct {
		name        string
		title, text string
		wantOK      bool
		wantSummary string
		wantStart   string // "15:04"
		wantEnd     string
	}{
		{
			name:        "range in the body",
			title:       "Планёрка",
			text:        "10:00-10:30",
			wantOK:      true,
			wantSummary: "Планёрка",
			wantStart:   "10:00", wantEnd: "10:30",
		},
		{
			name:        "russian preposition and single time",
			title:       "Встреча с подрядчиком в 14:00",
			wantOK:      true,
			wantSummary: "Встреча с подрядчиком",
			wantStart:   "14:00", wantEnd: "14:30",
		},
		{
			name:        "en dash range",
			title:       "Sprint review",
			text:        "Today 16:00 – 17:00, Meet",
			wantOK:      true,
			wantSummary: "Sprint review",
			wantStart:   "16:00", wantEnd: "17:00",
		},
		{
			name:        "до as a range separator",
			title:       "Созвон",
			text:        "с 11:30 до 12:15",
			wantOK:      true,
			wantSummary: "Созвон",
			wantStart:   "11:30", wantEnd: "12:15",
		},
		{
			name:        "dotted clock",
			title:       "Обед 13.00",
			wantOK:      true,
			wantSummary: "Обед",
			wantStart:   "13:00", wantEnd: "13:30",
		},
		{
			name:        "range crossing midnight",
			title:       "Ночной релиз",
			text:        "23:30-00:30",
			wantOK:      true,
			wantSummary: "Ночной релиз",
			wantStart:   "23:30", wantEnd: "00:30",
		},
		// The conservative half: no clock reading, no event.
		{name: "no time at all", title: "3 новых письма", wantOK: false},
		{name: "bare hour is not a time", title: "Планёрка в 14", wantOK: false},
		{name: "unread count", title: "Входящие", text: "12 непрочитанных", wantOK: false},
		{name: "a date is not a clock", title: "Отчёт", text: "срок 2026.08.15", wantOK: false},
		{name: "time but nothing named", title: "10:00-10:30", wantOK: false},
		{name: "impossible clock", title: "Смена 99:99", wantOK: false},
		{name: "empty", wantOK: false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ev, ok := EventFromNotificationIn(Notification{
				Package: "com.google.android.gm",
				Title:   tt.title,
				Text:    tt.text,
				Posted:  posted,
			}, posted.Location())
			if ok != tt.wantOK {
				t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
			}
			if !ok {
				return
			}
			if ev.Summary != tt.wantSummary {
				t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary)
			}
			if got := ev.Start.Format("15:04"); got != tt.wantStart {
				t.Errorf("start = %s, want %s", got, tt.wantStart)
			}
			if got := ev.End.Format("15:04"); got != tt.wantEnd {
				t.Errorf("end = %s, want %s", got, tt.wantEnd)
			}
			if !ev.End.After(ev.Start) {
				t.Errorf("end %v must be after start %v", ev.End, ev.Start)
			}
			// The event lands on the day the phone showed it, in the zone it
			// was resolved against — never shifted into UTC.
			if ev.Start.Location() != posted.Location() {
				t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
			}
			if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 {
				t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d)
			}
		})
	}
}

// A notification is not always about today. A 21:00 reminder reading
// "Tomorrow at 09:00" used to be dated to the notification's own day, which put
// the meeting twelve hours in the past and filed it under today in FactKey. A
// wrong meeting stored is worse than nothing stored.
func TestEventFromNotificationDayWords(t *testing.T) {
	evening := time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local)

	tests := []struct {
		name        string
		title, text string
		posted      time.Time
		wantOK      bool
		wantDay     int // day of month
		wantSummary string
	}{
		{
			name: "tomorrow in english", title: "Standup", text: "Tomorrow at 09:00",
			posted: evening, wantOK: true, wantDay: 4, wantSummary: "Standup",
		},
		{
			name: "завтра in russian", title: "Планёрка", text: "завтра в 09:00",
			posted: evening, wantOK: true, wantDay: 4, wantSummary: "Планёрка",
		},
		{
			name: "завтра in the title, summary in the body", title: "Завтра в 09:00", text: "Планёрка",
			posted: evening, wantOK: true, wantDay: 4, wantSummary: "Планёрка",
		},
		{
			name: "послезавтра is two days, not one", title: "Ретро", text: "послезавтра 11:00",
			posted: evening, wantOK: true, wantDay: 5, wantSummary: "Ретро",
		},
		{
			name: "сегодня stays on the posted day", title: "Созвон", text: "сегодня 21:30",
			posted: evening, wantOK: true, wantDay: 3, wantSummary: "Созвон",
		},
		// No day word: the 09:00 is twelve hours behind the notification, so the
		// inferred day is wrong and there is nothing honest to store.
		{
			name: "stale morning time with no day word", title: "Standup", text: "at 09:00",
			posted: evening, wantOK: false,
		},
		// Inside the grace: a phone reposting the notification for a meeting
		// already under way must still store it.
		{
			name: "meeting already running", title: "Планёрка", text: "20:30-22:00",
			posted: evening, wantOK: true, wantDay: 3, wantSummary: "Планёрка",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ev, ok := EventFromNotificationIn(Notification{
				Package: "com.google.android.calendar",
				Title:   tt.title, Text: tt.text, Posted: tt.posted,
			}, tt.posted.Location())
			if ok != tt.wantOK {
				t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
			}
			if !ok {
				return
			}
			if got := ev.Start.Day(); got != tt.wantDay {
				t.Errorf("start day = %d, want %d (start %v)", got, tt.wantDay, ev.Start)
			}
			if ev.Summary != tt.wantSummary {
				t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary)
			}
			if ev.Start.Before(tt.posted.Add(-ambientPastGrace)) {
				t.Errorf("start %v is stale against posted %v", ev.Start, tt.posted)
			}
		})
	}
}

// The day word named the date, which now lives in Start. Leaving it in the
// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds
// that into the key.
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
	ev, ok := EventFromNotificationIn(Notification{
		Title:  "Завтра Планёрка 09:00",
		Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
	}, time.UTC)
	if !ok {
		t.Fatal("expected an event")
	}
	if ev.Summary != "Планёрка" {
		t.Fatalf("summary = %q, want %q", ev.Summary, "Планёрка")
	}
}

// Vikunja #482. A relay that posts its instant as UTC used to hand the wall
// clock inside the text the same zone, so "созвон в 14:30" was stored as 14:30Z
// and read back as 18:30 on a UTC+4 box — late by exactly the deploy's offset,
// and correct-looking on a UTC one. Nobody writes a notification meaning 14:30Z.
func TestEventFromNotificationReadsTheClockAsLocalTime(t *testing.T) {
	ev, ok := EventFromNotification(Notification{
		Package: "com.slack",
		Title:   "Standup",
		Text:    "созвон в 14:30",
		Posted:  time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local
	})
	if !ok {
		t.Fatal("expected an event")
	}
	if got := ev.Start.Format("15:04"); got != "14:30" {
		t.Errorf("start = %s, want 14:30 local", got)
	}
	if ev.Start.Location() != time.Local {
		t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local)
	}
	if got, want := FactKey(ev), "calendar_event_20260802_Standup"; got != want {
		t.Errorf("fact key = %q, want %q", got, want)
	}
}

func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
	if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
		t.Error("a notification with no posted_at has no date to sit on")
	}
}

// An ambient event must never be indistinguishable from a calendar read.
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
	ev, ok := EventFromNotificationIn(Notification{
		Title:  "Планёрка 10:00-10:30",
		Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
	}, time.UTC)
	if !ok {
		t.Fatal("expected an event")
	}
	if FactKey(ev) == "" || FactValue(ev) == "" {
		t.Fatal("ambient events must use the shared fact encoding")
	}
	if AmbientConfidence >= 1.0 {
		t.Fatal("ambient confidence must be below a calendar read's")
	}
}

func TestStripClock(t *testing.T) {
	tests := []struct{ in, want string }{
		{"Встреча в 14:00", "Встреча"},
		{"Планёрка 10:00-10:30", "Планёрка"},
		{"с 11:30 до 12:15 Созвон", "Созвон"},
		{"Ничего", "Ничего"},
	}
	for _, tt := range tests {
		if got := stripClock(tt.in); got != tt.want {
			t.Errorf("stripClock(%q) = %q, want %q", tt.in, got, tt.want)
		}
	}
}

// The defect this file's zone handling exists for: a phone posts an RFC 3339
// instant ending in Z, and "созвон в 14:30" used to be resolved against that
// Z, so on a UTC+4 box the meeting was stored at 18:30. A wall clock in a
// notification is local by construction.
func TestNotificationClockIsLocalNotUTC(t *testing.T) {
	samara := time.FixedZone("+04", 4*3600)
	n := Notification{
		Package: "com.slack",
		Title:   "Standup",
		Text:    "созвон в 14:30",
		Posted:  time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local
	}
	ev, ok := EventFromNotificationIn(n, samara)
	if !ok {
		t.Fatal("expected an event")
	}
	if got := ev.Start.Format("15:04"); got != "14:30" {
		t.Errorf("start = %s local, want 14:30", got)
	}
	if got := ev.Start.UTC().Format("15:04"); got != "10:30" {
		t.Errorf("start = %sZ, want 10:30Z (14:30 at UTC+4)", got)
	}
	if ev.Start.Location() != samara {
		t.Errorf("location = %v, want %v", ev.Start.Location(), samara)
	}
	// The stored key is the local day, so it files under the day he lived.
	if want, got := "calendar_event_20260802_Standup", FactKeyIn(ev, samara); got != want {
		t.Errorf("FactKey = %q, want %q", got, want)
	}
}

// A notification posted late in the UTC evening is already the next day where
// he is standing. The day must come from the local clock, not from Posted's.
func TestNotificationDayIsTheLocalDay(t *testing.T) {
	samara := time.FixedZone("+04", 4*3600)
	n := Notification{
		Title:  "Планёрка 09:00",
		Text:   "завтра",
		Posted: time.Date(2026, 8, 2, 21, 0, 0, 0, time.UTC), // 03-08 01:00 local
	}
	ev, ok := EventFromNotificationIn(n, samara)
	if !ok {
		t.Fatal("expected an event")
	}
	if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 4 {
		t.Errorf("date = %d-%02d-%02d, want 2026-08-04 (tomorrow, locally)", y, m, d)
	}
}
