package memory

import (
	"strings"
	"testing"
	"time"
	"unicode"

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

// habitHistory — n weeks of the same weekday, at the given local time.
func habitHistory(key string, wd time.Weekday, hh, mm, weeks int, from time.Time) []Observation {
	var out []Observation
	d := from
	for d.Weekday() != wd {
		d = d.AddDate(0, 0, -1)
	}
	for i := 0; i < weeks; i++ {
		day := d.AddDate(0, 0, -7*i)
		out = append(out, Observation{
			At:   time.Date(day.Year(), day.Month(), day.Day(), hh, mm, 0, 0, from.Location()),
			Key:  key,
			Kind: "self",
		})
	}
	return out
}

func behaviorNow() time.Time {
	// A Monday, so "по вторникам" is a past weekday and not today.
	return time.Date(2026, 8, 3, 20, 0, 0, 0, time.UTC)
}

func TestBuildProfileCountsWeekdayHabits(t *testing.T) {
	now := behaviorNow()
	obs := append(
		habitHistory("workout", time.Tuesday, 19, 0, 4, now),
		habitHistory("water", time.Tuesday, 9, 0, 3, now)...,
	)
	p := BuildProfile(obs, now)

	tue := p.Weekly[time.Tuesday]
	if len(tue) != 2 {
		t.Fatalf("got %d tuesday activities, want 2: %+v", len(tue), tue)
	}
	// Most-established first.
	if tue[0].Key != "workout" || tue[0].Days != 4 {
		t.Errorf("first = %+v, want workout on 4 days", tue[0])
	}
	if tue[0].TypicalAt != 19*time.Hour {
		t.Errorf("typical at %v, want 19:00", tue[0].TypicalAt)
	}
	if len(p.Weekly[time.Wednesday]) != 0 {
		t.Errorf("wednesday must be empty: %+v", p.Weekly[time.Wednesday])
	}
	if len(p.All) != 2 {
		t.Errorf("the week-wide list should hold both: %+v", p.All)
	}
}

// A one-off is not a habit. Saying "ты обычно X" off a single row is a
// confidently wrong claim about his life.
func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
	now := behaviorNow()
	obs := habitHistory("workout", time.Tuesday, 19, 0, 1, now)
	// Three rows, same day — a busy Tuesday, not a habit.
	obs = append(obs, Observation{At: obs[0].At.Add(time.Hour), Key: "workout", Kind: "self"})
	obs = append(obs, Observation{At: obs[0].At.Add(2 * time.Hour), Key: "workout", Kind: "self"})

	p := BuildProfile(obs, now)
	if len(p.All) != 0 || len(p.Weekly) != 0 {
		t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
	}
	if got := p.FormatOverallRU(); !say.IsS(say.HabitOverallNone, nil, got) {
		t.Errorf("empty profile reads %q", got)
	}
}

// Only self-facts describe him. Env rows are the world and config rows are
// maven's own tuning state; counting either as a habit would be a category
// error the owner would then be told about.
func TestBuildProfileIgnoresNonSelfAndMachineryKeys(t *testing.T) {
	now := behaviorNow()
	var obs []Observation
	for _, o := range habitHistory("water", time.Tuesday, 9, 0, 3, now) {
		o.Kind = "env"
		obs = append(obs, o)
	}
	for _, o := range habitHistory("cooldown:water", time.Tuesday, 9, 0, 3, now) {
		obs = append(obs, o) // kind=self, but a machinery key
	}
	for _, o := range habitHistory("calendar_event_20260804_standup", time.Tuesday, 10, 0, 3, now) {
		obs = append(obs, o)
	}
	if p := BuildProfile(obs, now); len(p.All) != 0 {
		t.Fatalf("nothing here is a habit of his: %+v", p.All)
	}
}

// The median, not the mean: one 03:00 outlier must not move a morning habit
// into the night.
func TestBuildProfileTypicalTimeIsMedian(t *testing.T) {
	now := behaviorNow()
	obs := habitHistory("water", time.Tuesday, 9, 0, 4, now)
	obs = append(obs, Observation{At: obs[0].At.AddDate(0, 0, -28).Add(-6 * time.Hour), Key: "water", Kind: "self"})
	p := BuildProfile(obs, now)
	if len(p.All) != 1 {
		t.Fatalf("got %+v", p.All)
	}
	if p.All[0].TypicalAt != 9*time.Hour {
		t.Errorf("typical at %v, want 09:00 despite the outlier", p.All[0].TypicalAt)
	}
}

func TestProfileFormatRUPersona(t *testing.T) {
	now := behaviorNow()
	obs := append(
		habitHistory("workout", time.Tuesday, 19, 0, 4, now),
		habitHistory("water", time.Tuesday, 9, 5, 3, now)...,
	)
	p := BuildProfile(obs, now)

	got := p.FormatWeekdayRU(time.Tuesday)
	want := "по вторникам ты обычно тренируешься около 19:00 и пьёшь воду около 09:05."
	if got != want {
		t.Errorf("got  %q\nwant %q", got, want)
	}
	if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "постоянного") {
		t.Errorf("an unknown weekday reads %q", empty)
	}
	// Persona: she addresses him informally, never in the masculine about
	// herself, and never with a pet name.
	for _, s := range []string{got, p.FormatOverallRU(), p.FormatWeekdayRU(time.Thursday)} {
		// Whole words: "ничего" contains "его", and a substring test would
		// call a correct sentence a persona violation.
		for _, tok := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
			return !unicode.IsLetter(r)
		}) {
			switch tok {
			case "рад", "понял", "вы", "ваш", "ваши", "милый", "дорогой", "он", "его":
				t.Errorf("%q uses %q", s, tok)
			}
		}
	}
}

// An unrecognised key is read back verbatim rather than glossed into something
// maven made up.
func TestProfileUnknownKeyReadBackVerbatim(t *testing.T) {
	now := behaviorNow()
	p := BuildProfile(habitHistory("починил кран", time.Tuesday, 12, 0, 2, now), now)
	if got := p.FormatOverallRU(); !strings.Contains(got, "починил кран") {
		t.Errorf("got %q", got)
	}
}

// A future-dated row is a clock problem, not a habit.
func TestBuildProfileIgnoresFutureRows(t *testing.T) {
	now := behaviorNow()
	obs := habitHistory("water", time.Tuesday, 9, 0, 3, now.AddDate(0, 2, 0))
	if p := BuildProfile(obs, now); len(p.All) != 0 {
		t.Fatalf("future rows counted: %+v", p.All)
	}
}

// TestWeekdayProfileExcludesEverydayHabits — the "You drink water" case.
// Drinking water every day is not what he does on Saturdays, and answering
// with it makes the weekday question pointless.
func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
	now := time.Date(2026, 8, 1, 20, 0, 0, 0, time.UTC) // a Saturday
	var obs []Observation
	// water: twice a day, every day, for three weeks.
	for d := 1; d <= 21; d++ {
		day := now.AddDate(0, 0, -d)
		obs = append(obs,
			Observation{At: time.Date(day.Year(), day.Month(), day.Day(), 9, 0, 0, 0, time.UTC), Key: "water", Kind: "self"},
			Observation{At: time.Date(day.Year(), day.Month(), day.Day(), 18, 0, 0, 0, time.UTC), Key: "water", Kind: "self"})
	}
	// workout: Saturdays only.
	for _, d := range []int{7, 14, 21} {
		day := now.AddDate(0, 0, -d)
		obs = append(obs, Observation{
			At: time.Date(day.Year(), day.Month(), day.Day(), 11, 0, 0, 0, time.UTC), Key: "workout", Kind: "self"})
	}

	p := BuildProfile(obs, now)

	sat := p.Weekly[time.Saturday]
	if len(sat) != 1 || sat[0].Key != "workout" {
		t.Fatalf("Saturday should be characterised by workout alone, got %+v", sat)
	}
	if len(p.Everyday) != 1 || p.Everyday[0].Key != "water" {
		t.Fatalf("water should be an everyday habit, got %+v", p.Everyday)
	}

	got := p.FormatWeekdayRU(time.Saturday)
	if !strings.Contains(got, "тренируешься") {
		t.Fatalf("Saturday readout should name the workout: %q", got)
	}
	if strings.Contains(got, "воду") {
		t.Fatalf("Saturday readout must not recite the everyday habit: %q", got)
	}

	// A day with nothing of its own says so rather than reciting water as if
	// Wednesday were the reason for it.
	wed := p.FormatWeekdayRU(time.Wednesday)
	if !say.IsS(say.HabitWeekdaySame, map[string]string{
		"day": "средам", "items": "пьёшь воду около 13:30",
	}, wed) {
		t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
	}
}

// TestTypicalTimeIsCircular — the median is over a clock, not a number line.
// Bedtimes either side of midnight used to average to midday, which is the
// exact error the median was chosen to avoid, on the one activity most likely
// to cross the boundary.
func TestTypicalTimeIsCircular(t *testing.T) {
	now := behaviorNow()
	var obs []Observation
	for i, mins := range []int{23*60 + 40, 23*60 + 50, 10, 20} {
		day := now.AddDate(0, 0, -(i + 1))
		obs = append(obs, Observation{
			At:   time.Date(day.Year(), day.Month(), day.Day(), 0, mins, 0, 0, now.Location()),
			Key:  "sleep",
			Kind: "self",
		})
	}
	p := BuildProfile(obs, now)
	if len(p.All) != 1 {
		t.Fatalf("got %+v, want one activity", p.All)
	}
	got := p.All[0].TypicalAt
	if !p.All[0].HasTypical {
		t.Fatal("a four-observation cluster has a typical time")
	}
	if got < 23*time.Hour+55*time.Minute && got > 5*time.Minute {
		t.Errorf("typical bedtime = %v, want just either side of midnight", got)
	}
	if s := p.FormatOverallRU(); strings.Contains(s, "около 12:00") {
		t.Errorf("read back as %q", s)
	}
}

// Times spread across the whole clock have no typical value, and she must not
// name one.
func TestNoTypicalTimeWhenSpreadWide(t *testing.T) {
	now := behaviorNow()
	var obs []Observation
	for i, hh := range []int{2, 9, 16, 21} {
		day := now.AddDate(0, 0, -(i + 1))
		obs = append(obs, Observation{
			At:   time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
			Key:  "water",
			Kind: "self",
		})
	}
	p := BuildProfile(obs, now)
	if len(p.All) != 1 {
		t.Fatalf("got %+v, want one activity", p.All)
	}
	if p.All[0].HasTypical {
		t.Errorf("times spanning %v were given a typical value", p.All[0].TypicalAt)
	}
	if s := p.FormatOverallRU(); strings.Contains(s, "около") {
		t.Errorf("read back with a time she cannot support: %q", s)
	}
}

// TestKeysAreCanonicalisedBeforeCounting — the fact key comes out of the LLM
// with no allowlist behind it, so the same habit arrives spelled several ways.
// Counted separately, each spelling sits below MinHabitDays and the habit
// vanishes.
func TestKeysAreCanonicalisedBeforeCounting(t *testing.T) {
	now := behaviorNow()
	var obs []Observation
	for i, key := range []string{"water", "Воду", "выпил воды", "попил_воды"} {
		day := now.AddDate(0, 0, -(i + 1))
		obs = append(obs, Observation{
			At:   time.Date(day.Year(), day.Month(), day.Day(), 9, 0, 0, 0, now.Location()),
			Key:  key,
			Kind: "self",
		})
	}
	p := BuildProfile(obs, now)
	if len(p.All) != 1 {
		t.Fatalf("got %+v, want one activity — the spellings are one habit", p.All)
	}
	if p.All[0].Key != "water" || p.All[0].Days != 4 {
		t.Errorf("got %+v, want water on 4 days", p.All[0])
	}
	if s := p.FormatOverallRU(); !strings.Contains(s, "пьёшь воду") {
		t.Errorf("read back as %q, want the glossed canonical key", s)
	}
}

// An unglossed key is quoted, not read as a verb. "обычно ты выпил_воды около
// 09:00" is what reciting the raw key produced.
func TestUnglossedKeyIsQuoted(t *testing.T) {
	now := behaviorNow()
	p := BuildProfile(habitHistory("починил_кран", time.Tuesday, 12, 0, 2, now), now)
	got := p.FormatOverallRU()
	if !strings.Contains(got, "отмечаешь «починил кран»") {
		t.Errorf("got %q", got)
	}
}

// She says over what stretch of records "обычно" is claimed. Without it the
// same sentence comes out of three days and out of a year.
func TestOverallNamesThePeriod(t *testing.T) {
	now := behaviorNow()
	p := BuildProfile(habitHistory("water", time.Tuesday, 9, 0, 3, now), now)
	got := p.FormatOverallRU()
	if !strings.Contains(got, "по записям за последние 21 день") {
		t.Errorf("got %q, want the period spoken", got)
	}
}

// The no-data weekday answer is about his week, not about her. "у меня пока нет
// ничего постоянного" answers a question nobody asked. It does say "я не вижу",
// because what she is reporting is the state of her records — and it names the
// day, which is what makes it an answer to what he asked (Vikunja #521: "у тебя"
// came out as filler, the day carries it).
func TestEmptyWeekdayAnswerIsAboutHisWeek(t *testing.T) {
	p := BuildProfile(nil, behaviorNow())
	got := p.FormatWeekdayRU(time.Wednesday)
	if strings.Contains(got, "у меня") {
		t.Errorf("got %q", got)
	}
	if !strings.Contains(got, "средам") {
		t.Errorf("got %q, want the day he asked about", got)
	}
}

// "quiet" is machinery, but only as a whole key. As a bare five-letter prefix
// it silently swallowed any future self-fact key starting with those letters.
func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) {
	now := behaviorNow()
	p := BuildProfile(habitHistory("quietude", time.Tuesday, 8, 0, 3, now), now)
	if len(p.All) != 1 {
		t.Fatalf("got %+v, want the key counted", p.All)
	}
	p = BuildProfile(habitHistory("quiet_hours", time.Tuesday, 8, 0, 3, now), now)
	if len(p.All) != 0 {
		t.Fatalf("got %+v, want maven's own tuning state dropped", p.All)
	}
}

// "по выходным" is a question about two days, answered as two days.
func TestFormatWeekendRU(t *testing.T) {
	now := behaviorNow()
	obs := append(
		habitHistory("workout", time.Saturday, 11, 0, 3, now),
		habitHistory("walk", time.Sunday, 15, 0, 3, now)...,
	)
	got := BuildProfile(obs, now).FormatWeekendRU()
	if !strings.Contains(got, "по субботам") || !strings.Contains(got, "по воскресеньям") {
		t.Errorf("got %q, want both weekend days named", got)
	}
	empty := BuildProfile(nil, now).FormatWeekendRU()
	if strings.Contains(empty, "у меня") {
		t.Errorf("got %q", empty)
	}
}
