package dialogue

import (
	"reflect"
	"testing"
	"time"
)

var base = time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)

func TestPendingQuestionIsExpired(t *testing.T) {
	cases := []struct {
		name string
		ttl  time.Duration
		now  time.Time
		want bool
	}{
		{"fresh", time.Minute, base.Add(10 * time.Second), false},
		{"exactly at ttl", time.Minute, base.Add(time.Minute), false},
		{"past ttl", time.Minute, base.Add(2 * time.Minute), true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			q := &PendingQuestion{Asked: base, TTL: tc.ttl}
			if got := q.IsExpired(tc.now); got != tc.want {
				t.Fatalf("IsExpired = %v, want %v", got, tc.want)
			}
		})
	}
}

func TestClarifyStoreGetPutDelete(t *testing.T) {
	s := NewClarifyStore(time.Minute)

	if got := s.Get("voice", base); got != nil {
		t.Fatalf("empty store returned %+v", got)
	}

	q := &PendingQuestion{Intent: IntentReminder, Missing: []Slot{SlotTime}, Asked: base}
	s.Put("voice", q)
	if q.TTL != time.Minute {
		t.Fatalf("Put did not apply the default TTL, got %v", q.TTL)
	}
	if got := s.Get("voice", base.Add(time.Second)); got != q {
		t.Fatalf("Get returned %+v, want the parked question", got)
	}

	// Expired questions are dropped on read, not returned.
	if got := s.Get("voice", base.Add(2*time.Minute)); got != nil {
		t.Fatalf("expired Get returned %+v", got)
	}
	if got := s.Get("voice", base); got != nil {
		t.Fatalf("expired question was not deleted: %+v", got)
	}

	s.Put("voice", &PendingQuestion{Asked: base, TTL: time.Hour})
	s.Delete("voice")
	if got := s.Get("voice", base); got != nil {
		t.Fatalf("Delete left %+v", got)
	}
}

// TestClarifyStoreTakeExpired — TakeExpired reports (and drops) only a question
// whose TTL ran out.
func TestClarifyStoreTakeExpired(t *testing.T) {
	s := NewClarifyStore(time.Minute)
	if n := s.TakeExpired("voice", base); n != 0 {
		t.Fatalf("nothing parked ⇒ nothing expired, got %d", n)
	}
	s.Put("voice", &PendingQuestion{Missing: []Slot{SlotTime}, Asked: base, TTL: time.Minute})
	if n := s.TakeExpired("voice", base.Add(30*time.Second)); n != 0 {
		t.Fatalf("a live question must not report as expired, got %d", n)
	}
	if s.Get("voice", base.Add(30*time.Second)) == nil {
		t.Fatal("a live question must survive TakeExpired")
	}
	if n := s.TakeExpired("voice", base.Add(2*time.Minute)); n != 1 {
		t.Fatalf("a stale question must report as one expired, got %d", n)
	}
	if n := s.TakeExpired("voice", base.Add(2*time.Minute)); n != 0 {
		t.Fatalf("TakeExpired must drop the question, so the second call is 0, got %d", n)
	}
}

func TestNewClarifyStoreDefaultTTL(t *testing.T) {
	s := NewClarifyStore(0)
	q := &PendingQuestion{Asked: base}
	s.Put("voice", q)
	if q.TTL != 90*time.Second {
		t.Fatalf("TTL = %v, want 90s", q.TTL)
	}
}

func TestAnswerFillsOnlyMissingSlots(t *testing.T) {
	answerTime := base.Add(3 * time.Hour)
	other := base.Add(9 * time.Hour)

	cases := []struct {
		name    string
		parked  Slots
		missing []Slot
		text    string
		answer  Slots
		want    Slots
	}{
		{
			name:    "fills the missing time",
			parked:  Slots{Text: "напомни позвонить"},
			missing: []Slot{SlotTime},
			text:    "в три",
			answer:  Slots{Time: answerTime, HasTime: true},
			want:    Slots{Text: "напомни позвонить", Time: answerTime, HasTime: true},
		},
		{
			// He restated it: «нет, в пять». The new value wins.
			name:    "a restated time overwrites the parked one",
			parked:  Slots{Time: other, HasTime: true},
			missing: []Slot{SlotTime},
			text:    "нет, в три",
			answer:  Slots{Time: answerTime, HasTime: true},
			want:    Slots{Time: answerTime, HasTime: true},
		},
		{
			name:    "ignores slots that were not missing",
			parked:  Slots{Key: "water", HasKey: true},
			missing: []Slot{SlotValue},
			text:    "два литра",
			answer:  Slots{Key: "sleep", HasKey: true, Value: "2l"},
			want:    Slots{Key: "water", HasKey: true, Value: "2l"},
		},
		{
			name:    "fills key when empty",
			parked:  Slots{},
			missing: []Slot{SlotKey, SlotValue},
			text:    "воды",
			answer:  Slots{Key: "water", HasKey: true, Value: `"drank"`},
			want:    Slots{Key: "water", HasKey: true, Value: `"drank"`},
		},
		{
			name:    "fills fn and its args",
			parked:  Slots{},
			missing: []Slot{SlotFn},
			text:    "перезапусти nginx",
			answer:  Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
			want:    Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
		},
		{
			name:    "a restated fn replaces the fn and its args",
			parked:  Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
			missing: []Slot{SlotFn},
			text:    "останови postgres",
			answer:  Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true},
			want:    Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true},
		},
		{
			name:    "raw answer becomes the text when nothing was parsed",
			parked:  Slots{},
			missing: []Slot{SlotText},
			text:    "купить хлеб",
			answer:  Slots{},
			want:    Slots{Text: "купить хлеб"},
		},
		{
			name:    "parsed text wins over the raw answer",
			parked:  Slots{},
			missing: []Slot{SlotText},
			text:    "запиши купить хлеб",
			answer:  Slots{Text: "купить хлеб"},
			want:    Slots{Text: "купить хлеб"},
		},
		{
			name:    "empty answer leaves the slot missing",
			parked:  Slots{Text: "напомни"},
			missing: []Slot{SlotTime},
			text:    "не знаю",
			answer:  Slots{},
			want:    Slots{Text: "напомни"},
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			q := &PendingQuestion{Slots: tc.parked, Missing: tc.missing, Asked: base}
			// Whole-struct compare: a new field in Slots is covered for free.
			if got := q.Answer(tc.text, tc.answer); !reflect.DeepEqual(got, tc.want) {
				t.Fatalf("Answer = %+v, want %+v", got, tc.want)
			}
		})
	}
}

func TestCanAskAllowsThreeQuestionsByDefault(t *testing.T) {
	if DefaultMaxAttempts != 3 {
		t.Fatalf("DefaultMaxAttempts = %d, want 3", DefaultMaxAttempts)
	}
	q := &PendingQuestion{Asked: base} // MaxAttempts unset ⇒ the default
	for i := 0; i < 3; i++ {
		if !q.CanAsk() {
			t.Fatalf("question %d should be allowed", i+1)
		}
		q.Attempts++
	}
	if q.CanAsk() {
		t.Fatal("a fourth question must not be allowed")
	}
}

func TestCanAskHonoursConfiguredMax(t *testing.T) {
	q := &PendingQuestion{Asked: base, MaxAttempts: 1, Attempts: 1}
	if q.CanAsk() {
		t.Fatal("MaxAttempts 1 means one question only")
	}
}

func TestStillMissing(t *testing.T) {
	want := []Slot{SlotTime, SlotKey, SlotValue, SlotFn, SlotText}
	cases := []struct {
		name  string
		slots Slots
		want  []Slot
	}{
		{"all empty", Slots{}, want},
		{
			name:  "all filled",
			slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Value: "1l", Fn: "restart", HasFn: true, Text: "t"},
			want:  nil,
		},
		{
			name:  "only value left",
			slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Fn: "restart", HasFn: true, Text: "t"},
			want:  []Slot{SlotValue},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := StillMissing(want, tc.slots)
			if len(got) != len(tc.want) {
				t.Fatalf("StillMissing = %v, want %v", got, tc.want)
			}
			for i := range got {
				if got[i] != tc.want[i] {
					t.Fatalf("StillMissing = %v, want %v", got, tc.want)
				}
			}
		})
	}
}
