package main

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

	"github.com/kami/maven/internal/config"
	"github.com/kami/maven/internal/ipc"
	"github.com/kami/maven/internal/phraser"
	"github.com/kami/maven/internal/router"
	"github.com/kami/maven/internal/rss"
	"github.com/kami/maven/internal/voice"
)

// buildFeedHandler — a handler with the given feed notes already stored. No
// embedder: the feed source answers from recent notes by source, which is what
// makes it work for notes written before an embedder existed.
func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHandler {
	t.Helper()
	ctx := context.Background()
	st := newTestStore(t)
	now := time.Now()
	for i, n := range notes {
		ts := now.Add(time.Duration(i) * time.Minute)
		if _, err := st.WriteNote(ctx, ts, n.Text, nil, n.Source); err != nil {
			t.Fatalf("WriteNote: %v", err)
		}
	}
	return &reactiveHandler{
		api:     ipc.NewStoreAPI(st),
		replier: voice.NewStubReplier(),
		phraser: phraser.NewStub(),
		now:     func() time.Time { return now },
		feedsOn: feedsOn,
		recall:  recallWiring{embedder: nil},
	}
}

func askFeeds(t *testing.T, h *reactiveHandler, q string) (string, bool) {
	t.Helper()
	return h.queryFeeds(context.Background(), &queryTurn{
		dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
	})
}

func TestQueryFeedsReadsFeedNotes(t *testing.T) {
	h := buildFeedHandler(t, true,
		ipc.Note{Text: "Новая уязвимость в ядре [технологии]\nпатч вышел\nhttps://example.org/a", Source: "rss:habr"},
		ipc.Note{Text: "что-то он сам сказал", Source: "tap:voice"},
	)
	reply, ok := askFeeds(t, h, "что нового в лентах?")
	if !ok {
		t.Fatal("the feed source did not claim the question")
	}
	if !strings.Contains(reply, "уязвимость") {
		t.Errorf("reply = %q, want the headline", reply)
	}
	if strings.Contains(reply, "он сам сказал") {
		t.Errorf("a note he dictated leaked into the feed answer: %q", reply)
	}
	// She reads the headline, not the summary and not the URL.
	if strings.Contains(reply, "https://") || strings.Contains(reply, "патч вышел") {
		t.Errorf("reply = %q, want the title line only", reply)
	}
}

func TestQueryFeedsByCategory(t *testing.T) {
	h := buildFeedHandler(t, true,
		ipc.Note{Text: "Релиз ядра [технологии]", Source: "rss:habr"},
		ipc.Note{Text: "Выборы отложены [политика]", Source: "rss:news"},
	)
	reply, ok := askFeeds(t, h, "что нового по технологиям?")
	if !ok {
		t.Fatal("not claimed")
	}
	if !strings.Contains(reply, "ядра") || strings.Contains(reply, "Выборы") {
		t.Fatalf("reply = %q, want only the технологии item", reply)
	}
	reply, _ = askFeeds(t, h, "что нового по спорту?")
	if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) {
		t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
	}
}

// "не настроены" and "ничего нового" are different truths, and neither may be
// answered by the model inventing a bulletin.
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
	// Against the entries, not against a substring: both of these have several
	// wordings, so "ничего нового" passed only on the turns the picker happened
	// to choose the first one.
	off := buildFeedHandler(t, false)
	reply, ok := askFeeds(t, off, "что нового в лентах?")
	if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
		t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
	}
	on := buildFeedHandler(t, true)
	reply, ok = askFeeds(t, on, "что нового в лентах?")
	if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) {
		t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
	}
	if phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
		t.Fatalf("an empty feed answered as an unconfigured one: %q", reply)
	}
}

func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
	h := buildFeedHandler(t, true)
	if reply, ok := askFeeds(t, h, "напомни полить цветы"); ok {
		t.Fatalf("claimed an unrelated question with %q", reply)
	}
	// The bare greeting is not a request for headlines. It used to be answered
	// with a configuration status.
	if reply, ok := askFeeds(t, h, "что нового?"); ok {
		t.Fatalf("claimed a greeting with %q", reply)
	}
}

// A busy day of his own notes must not push the newest headline out of the
// window the feed answer scans.
func TestQueryFeedsIsNotCrowdedOutByHisOwnNotes(t *testing.T) {
	notes := []ipc.Note{{Text: "Релиз ядра [технологии]", Source: "rss:habr"}}
	for i := 0; i < feedNoteWindow+10; i++ {
		notes = append(notes, ipc.Note{Text: "мысль вслух", Source: "tap:voice"})
	}
	h := buildFeedHandler(t, true, notes...)
	reply, ok := askFeeds(t, h, "что нового в лентах?")
	if !ok || !strings.Contains(reply, "ядра") {
		t.Fatalf("reply = %q, ok = %v; the headline fell out of the window", reply, ok)
	}
}

// The mark is what stops a restart from re-noting yesterday's headlines, so the
// fact round-trip is worth a test of its own.
func TestFactMarksRoundTrip(t *testing.T) {
	st := newTestStore(t)
	m := &factMarks{api: ipc.NewStoreAPI(st)}
	ctx := context.Background()

	at, err := m.LastMark(ctx, "habr")
	if err != nil || !at.IsZero() {
		t.Fatalf("no mark yet: got %v, %v — want zero time and no error", at, err)
	}
	want := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC)
	if err := m.SetMark(ctx, "habr", want); err != nil {
		t.Fatal(err)
	}
	got, err := m.LastMark(ctx, "habr")
	if err != nil {
		t.Fatal(err)
	}
	if !got.Equal(want) {
		t.Fatalf("mark = %v, want %v", got, want)
	}
}

// Off unless configured, checked at the wiring seam: no `feeds` block ⇒ no
// worker ⇒ no outbound request is possible.
func TestNewFeedWorkerOffByDefault(t *testing.T) {
	st := newTestStore(t)
	api := ipc.NewStoreAPI(st)
	if w := newFeedWorker(api, nil, &config.Config{}); w != nil {
		t.Fatal("a config with no feeds block wired a feed worker")
	}
	// An empty sources list is normalised to "off" by config.Load; the worker
	// refuses it too, so a hand-built Config cannot switch it on by accident.
	if w := newFeedWorker(api, nil, &config.Config{Feeds: &config.FeedsConfig{}}); w != nil {
		t.Fatal("an empty sources list wired a feed worker")
	}
	cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
		{Name: "habr", URL: "https://example.org/rss"},
	}}}
	w := newFeedWorker(api, nil, cfg)
	if w == nil {
		t.Fatal("a configured feed did not wire a worker")
	}
	if got := w.poller.Feeds(); len(got) != 1 || got[0].Name != "habr" {
		t.Fatalf("feeds = %+v", got)
	}
}

// The fetcher the worker builds must be allowlisted to the configured feeds and
// nothing else — the crawler's SSRF guards are only worth as much as the
// allowlist handed to them.
func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
	cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
		{Name: "habr", URL: "https://feeds.example.org/rss"},
	}}}
	w := newFeedWorker(ipc.NewStoreAPI(newTestStore(t)), nil, cfg)
	if w == nil {
		t.Fatal("no worker")
	}
	// PollFeed goes through the guarded fetcher; a feed URL pointing at the box
	// itself must fail rather than be read.
	_, err := w.poller.PollFeed(context.Background(), rss.FeedConfig{
		Name: "evil", URL: "http://127.0.0.1:9100/mcp",
	}, time.Now())
	if err == nil {
		t.Fatal("the poller fetched a private address")
	}
}

// TestQueryFeedsPassesWhenTheWorldCanAnswer — the defect (Vikunja #474). The
// deployed box has no feeds block and does have SearXNG, and "что происходит
// сейчас в новостях про искусственный интеллект?" got a configuration status
// instead of the live answer sitting one source below.
func TestQueryFeedsPassesWhenTheWorldCanAnswer(t *testing.T) {
	h := buildFeedHandler(t, false)
	h.search = &searchWiring{max: 3, runes: 1500}

	if reply, ok := askFeeds(t, h, "что нового в лентах?"); ok {
		t.Fatalf("feeds off with a search configured must fall through, got %q", reply)
	}
	// With nothing below that reads the world, the honest status is still said:
	// general knowledge would otherwise answer with an invented bulletin.
	h.search = nil
	if reply, ok := askFeeds(t, h, "что нового в лентах?"); !ok || !strings.Contains(reply, "не настроены") {
		t.Fatalf("no search and no ZIMs: reply = %q, ok = %v", reply, ok)
	}
}
