package main

import (
	"context"
	"strings"
	"testing"
	"time"

	"github.com/kami/maven/internal/ipc"
	"github.com/kami/maven/internal/router"
	"github.com/kami/maven/internal/zenmoney"
)

// moneyAPI answers only LatestFactBySource; everything else is unimplemented,
// which is the assertion that answering a money question costs no model call
// and reaches no network.
type moneyAPI struct {
	ipc.UnimplementedCoreAPI

	fact    ipc.Fact
	err     error
	gotKey  string
	gotSrc  string
	callCnt int
}

func (a *moneyAPI) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
	a.gotKey, a.gotSrc = key, source
	a.callCnt++
	return a.fact, a.err
}

func moneyNow() time.Time { return time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) }

func moneyFact(ts time.Time, val string) ipc.Fact {
	return ipc.Fact{Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: ts}
}

func TestQueryMoneyAnswersFromTheFact(t *testing.T) {
	api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":1749.5}],"count":3}`)}
	h := &reactiveHandler{api: api, now: moneyNow}
	reply, ok := h.queryMoney(context.Background(), &queryTurn{
		dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
	})
	if !ok {
		t.Fatal("the money source must claim a money question")
	}
	if api.gotKey != zenmoney.KeySpentMonth || api.gotSrc != zenmoney.Source {
		t.Errorf("read %q/%q, want the month key from the poller's source", api.gotKey, api.gotSrc)
	}
	if !strings.Contains(reply, "1749.5") {
		t.Errorf("reply = %q, want the exact figure", reply)
	}
	if !strings.Contains(reply, "в этом месяце") {
		t.Errorf("reply = %q, want the window named", reply)
	}
}

func TestQueryMoneyPicksTodaysKey(t *testing.T) {
	api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":250}],"count":1}`)}
	h := &reactiveHandler{api: api, now: moneyNow}
	if _, ok := h.queryMoney(context.Background(), &queryTurn{
		dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
	}); !ok {
		t.Fatal("expected the source to claim it")
	}
	if api.gotKey != zenmoney.KeySpentToday {
		t.Errorf("key = %q, want today's", api.gotKey)
	}
}

// The capability is off unless configured, and then there is no fact. She says
// so instead of letting the recall pass answer a money question from a note.
func TestQueryMoneySaysNotConnected(t *testing.T) {
	h := &reactiveHandler{api: &moneyAPI{err: ipc.ErrNoFact}, now: moneyNow}
	reply, ok := h.queryMoney(context.Background(), &queryTurn{
		dec: router.Decision{Utterance: "сколько я потратил?"},
	})
	if !ok {
		t.Fatal("expected the source to claim it")
	}
	if !strings.Contains(reply, "не подключено") {
		t.Errorf("reply = %q, want an honest 'not connected'", reply)
	}
	// No number of any kind in that answer.
	for _, d := range []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} {
		if strings.Contains(reply, d) {
			t.Errorf("reply %q contains a digit — nothing was read, so there is no figure", reply)
		}
	}
}

// A fact older than the staleness bound is dated rather than spoken as if it
// were current: the poller can be down, and last week's total presented as
// today's is a lie by omission.
func TestQueryMoneyDatesAStaleFact(t *testing.T) {
	old := moneyNow().Add(-72 * time.Hour)
	api := &moneyAPI{fact: moneyFact(old, `{"spent":[{"currency":"RUB","amount":100}],"count":1}`)}
	h := &reactiveHandler{api: api, now: moneyNow}
	reply, _ := h.queryMoney(context.Background(), &queryTurn{
		dec: router.Decision{Utterance: "сколько я потратил?"},
	})
	if !strings.Contains(reply, "данные от") {
		t.Errorf("reply = %q, want the stale fact dated", reply)
	}
}

func TestQueryMoneyPassesOtherQuestions(t *testing.T) {
	api := &moneyAPI{}
	h := &reactiveHandler{api: api, now: moneyNow}
	for _, u := range []string{"какая погода?", "я потратил весь день на это", "какие у меня задачи?"} {
		if _, ok := h.queryMoney(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
			t.Errorf("the money source claimed %q", u)
		}
	}
	if api.callCnt != 0 {
		t.Error("a non-money question must not read the money facts")
	}
}

// Money must be answered before the recall sources, or a question about
// spending gets answered by the nearest note.
func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
	moneyAt, notesAt := -1, -1
	for i, src := range querySources {
		switch src.name {
		case "money":
			moneyAt = i
		case "notes":
			notesAt = i
		}
	}
	if moneyAt < 0 || notesAt < 0 {
		t.Fatalf("sources missing: money=%d notes=%d", moneyAt, notesAt)
	}
	if moneyAt > notesAt {
		t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
	}
}

// The day window rolls over at midnight and the poller writes nothing until the
// first spend of the new day, so the last money_today fact is fresh by ts and
// covers yesterday. No staleness check can catch that.
func TestQueryMoneyRefusesYesterdaysDayTotal(t *testing.T) {
	yesterday, _ := zenmoney.DayWindow(moneyNow().AddDate(0, 0, -1))
	sum := zenmoney.Summary{From: yesterday, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 1749.5}}, Count: 3}
	val, ok := sum.Value(moneyNow().AddDate(0, 0, -1).Add(2 * time.Hour))
	if !ok {
		t.Fatal("want a fact value")
	}
	api := &moneyAPI{fact: ipc.Fact{
		Kind: "env", Key: zenmoney.KeySpentToday, Value: val,
		Source: zenmoney.Source, Ts: moneyNow().Add(-11 * time.Hour),
	}}
	h := &reactiveHandler{api: api, now: moneyNow}
	reply, claimed := h.queryMoney(context.Background(), &queryTurn{
		dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
	})
	if !claimed {
		t.Fatal("expected the source to claim it")
	}
	if strings.Contains(reply, "1749.5") {
		t.Errorf("reply = %q — that is yesterday's spending spoken as today's", reply)
	}
}

// Ts advances only when the number moves, so a quiet month used to be reported
// as stale while being current. The read stamp inside the value is what the
// staleness check means.
func TestQueryMoneyMeasuresStalenessFromTheRead(t *testing.T) {
	from, _ := zenmoney.MonthWindow(moneyNow())
	sum := zenmoney.Summary{From: from, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}}, Count: 1}
	val, _ := sum.Value(moneyNow().Add(-time.Hour))
	// The fact itself last CHANGED three days ago: nothing was spent since.
	api := &moneyAPI{fact: ipc.Fact{
		Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
		Source: zenmoney.Source, Ts: moneyNow().Add(-72 * time.Hour),
	}}
	h := &reactiveHandler{api: api, now: moneyNow}
	reply, _ := h.queryMoney(context.Background(), &queryTurn{
		dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
	})
	if strings.Contains(reply, "данные от") {
		t.Errorf("reply = %q — the figure was read an hour ago and is current", reply)
	}
}

// Two windows are stored and no others. Answering "вчера" with the
// month-to-date total answers a different question with a real number.
func TestQueryMoneyRefusesWindowsItDoesNotKeep(t *testing.T) {
	api := &moneyAPI{}
	h := &reactiveHandler{api: api, now: moneyNow}
	reply, ok := h.queryMoney(context.Background(), &queryTurn{
		dec: router.Decision{Utterance: "сколько я потратил вчера?"},
	})
	if !ok {
		t.Fatal("a money question must be claimed, not passed to recall")
	}
	if !strings.Contains(reply, "только") {
		t.Errorf("reply = %q, want her to say which windows she keeps", reply)
	}
	if api.callCnt != 0 {
		t.Error("a window she does not keep must not read a fact")
	}
}

// "сколько я заработал" reads the same fact and must lead with the income.
func TestQueryMoneyLeadsWithIncomeWhenAsked(t *testing.T) {
	from, _ := zenmoney.MonthWindow(moneyNow())
	sum := zenmoney.Summary{
		From:   from,
		Spent:  []zenmoney.Money{{Currency: "RUB", Amount: 100}},
		Earned: []zenmoney.Money{{Currency: "RUB", Amount: 3000}},
		Count:  2,
	}
	val, _ := sum.Value(moneyNow())
	api := &moneyAPI{fact: ipc.Fact{
		Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
		Source: zenmoney.Source, Ts: moneyNow(),
	}}
	h := &reactiveHandler{api: api, now: moneyNow}
	reply, _ := h.queryMoney(context.Background(), &queryTurn{
		dec: router.Decision{Utterance: "сколько я заработал в этом месяце?"},
	})
	if strings.Index(reply, "3000") > strings.Index(reply, "100") {
		t.Errorf("reply = %q, want the income he asked about first", reply)
	}
}
