package rss

import (
	"context"
	"errors"
	"strings"
	"testing"
	"time"
)

type fakeFetch struct {
	body  string
	err   error
	calls int
	urls  []string
}

func (f *fakeFetch) Get(_ context.Context, url string) (*Body, error) {
	f.calls++
	f.urls = append(f.urls, url)
	if f.err != nil {
		return nil, f.err
	}
	return &Body{Bytes: []byte(f.body)}, nil
}

type writtenNote struct {
	ts     time.Time
	text   string
	source string
	vec    []float32
}

type fakeNotes struct{ notes []writtenNote }

func (n *fakeNotes) WriteNote(_ context.Context, ts time.Time, text string, vec []float32, source string) (int64, error) {
	n.notes = append(n.notes, writtenNote{ts, text, source, vec})
	return int64(len(n.notes)), nil
}

type fakeMarks struct{ m map[string]time.Time }

func newMarks() *fakeMarks { return &fakeMarks{m: map[string]time.Time{}} }
func (f *fakeMarks) LastMark(_ context.Context, feed string) (time.Time, error) {
	return f.m[feed], nil
}
func (f *fakeMarks) SetMark(_ context.Context, feed string, at time.Time) error {
	f.m[feed] = at
	return nil
}

var now = time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)

func TestPollWritesNotesWithSource(t *testing.T) {
	fetch := &fakeFetch{body: rss2}
	notes := &fakeNotes{}
	marks := newMarks()
	p := NewPoller([]FeedConfig{{Name: "habr", URL: "https://example.org/rss", Category: "технологии"}},
		fetch, notes, marks, nil, nil, Config{})
	if p == nil {
		t.Fatal("NewPoller returned nil for a configured feed")
	}
	n := p.PollDue(context.Background(), now)
	if n != 2 || len(notes.notes) != 2 {
		t.Fatalf("wrote %d notes (returned %d), want 2", len(notes.notes), n)
	}
	if notes.notes[0].source != "rss:habr" {
		t.Errorf("source = %q, want rss:habr", notes.notes[0].source)
	}
	if !strings.Contains(notes.notes[0].text, "технологии") {
		t.Errorf("note does not carry its category: %q", notes.notes[0].text)
	}
	if !strings.Contains(notes.notes[0].text, "https://example.org/a") {
		t.Errorf("note does not carry its link: %q", notes.notes[0].text)
	}
	// The undated item is stamped with now, the dated one with its own date.
	if !notes.notes[1].ts.Equal(now) {
		t.Errorf("undated item ts = %v, want now", notes.notes[1].ts)
	}
}

// The whole point of a mark: polling twice must not re-note the same headlines.
func TestSecondPollIsQuiet(t *testing.T) {
	fetch := &fakeFetch{body: rss2}
	notes := &fakeNotes{}
	p := NewPoller([]FeedConfig{{Name: "habr", URL: "u", Interval: time.Minute}}, fetch, notes, newMarks(), nil, nil, Config{})
	p.PollDue(context.Background(), now)
	before := len(notes.notes)
	p.PollDue(context.Background(), now.Add(2*time.Minute))
	if len(notes.notes) != before {
		t.Fatalf("second poll wrote %d extra notes", len(notes.notes)-before)
	}
}

// A mark that survives a restart is the durable half; simulate one by building a
// fresh poller over the same marks.
func TestMarkSurvivesRestart(t *testing.T) {
	marks := newMarks()
	fetch := &fakeFetch{body: rss2}
	notes := &fakeNotes{}
	feeds := []FeedConfig{{Name: "habr", URL: "u"}}
	NewPoller(feeds, fetch, notes, marks, nil, nil, Config{}).PollDue(context.Background(), now)
	if len(notes.notes) != 2 {
		t.Fatalf("first run wrote %d", len(notes.notes))
	}
	notes2 := &fakeNotes{}
	NewPoller(feeds, fetch, notes2, marks, nil, nil, Config{}).PollDue(context.Background(), now.Add(time.Hour))
	// The dated item is behind the mark. The undated one has no date to compare,
	// so it comes back — accepted and documented in fresh(): an undated feed is
	// deduped per process, not forever.
	for _, n := range notes2.notes {
		if strings.Contains(n.text, "уязвимость") {
			t.Fatalf("dated item re-noted after restart: %q", n.text)
		}
	}
}

func TestIntervalIsRespected(t *testing.T) {
	fetch := &fakeFetch{body: rss2}
	p := NewPoller([]FeedConfig{{Name: "habr", URL: "u", Interval: time.Hour}}, fetch, &fakeNotes{}, newMarks(), nil, nil, Config{})
	p.PollDue(context.Background(), now)
	p.PollDue(context.Background(), now.Add(time.Minute))
	if fetch.calls != 1 {
		t.Fatalf("fetched %d times inside one interval, want 1", fetch.calls)
	}
	p.PollDue(context.Background(), now.Add(2*time.Hour))
	if fetch.calls != 2 {
		t.Fatalf("fetched %d times, want 2 after the interval elapsed", fetch.calls)
	}
}

func TestColdStartIgnoresOldItems(t *testing.T) {
	old := `<rss><channel><item><title>Старое</title><link>l</link>` +
		`<pubDate>Mon, 01 Jun 2026 10:00:00 +0000</pubDate></item></channel></rss>`
	notes := &fakeNotes{}
	p := NewPoller([]FeedConfig{{Name: "f", URL: "u"}}, &fakeFetch{body: old}, notes, newMarks(), nil, nil, Config{MaxAge: 24 * time.Hour})
	if n := p.PollDue(context.Background(), now); n != 0 {
		t.Fatalf("cold start imported %d old items, want 0", n)
	}
}

func TestMaxItemsCap(t *testing.T) {
	var b strings.Builder
	b.WriteString("<rss><channel>")
	for i := 0; i < 10; i++ {
		b.WriteString("<item><title>t")
		b.WriteByte(byte('0' + i))
		b.WriteString("</title><link>https://example.org/")
		b.WriteByte(byte('0' + i))
		b.WriteString("</link></item>")
	}
	b.WriteString("</channel></rss>")
	notes := &fakeNotes{}
	p := NewPoller([]FeedConfig{{Name: "f", URL: "u"}}, &fakeFetch{body: b.String()}, notes, newMarks(), nil, nil, Config{MaxItems: 3})
	if n := p.PollDue(context.Background(), now); n != 3 {
		t.Fatalf("wrote %d notes, want the cap of 3", n)
	}
}

func TestKeywordFilter(t *testing.T) {
	f := FeedConfig{Include: []string{"ядр"}, Exclude: []string{"реклама"}}
	if !Matches(f, Item{Title: "Новое ядро"}) {
		t.Error("include did not match")
	}
	if Matches(f, Item{Title: "Новое ядро", Summary: "Реклама внутри"}) {
		t.Error("exclude must win over include")
	}
	if Matches(f, Item{Title: "Погода"}) {
		t.Error("non-matching item passed the include filter")
	}
	if !Matches(FeedConfig{}, Item{Title: "что угодно"}) {
		t.Error("an unfiltered feed must keep everything")
	}
}

type fakeRanker struct{ keep bool }

func (r fakeRanker) Relevant(context.Context, string) (bool, error) { return r.keep, nil }

func TestRankerCanDropEverything(t *testing.T) {
	notes := &fakeNotes{}
	p := NewPoller([]FeedConfig{{Name: "f", URL: "u"}}, &fakeFetch{body: rss2}, notes, newMarks(), nil, fakeRanker{false}, Config{})
	if n := p.PollDue(context.Background(), now); n != 0 {
		t.Fatalf("ranker rejected everything but %d notes were written", n)
	}
}

func TestFetchErrorIsSurvivable(t *testing.T) {
	notes := &fakeNotes{}
	p := NewPoller([]FeedConfig{
		{Name: "dead", URL: "u1"},
		{Name: "live", URL: "u2"},
	}, &fakeFetch{err: errors.New("boom")}, notes, newMarks(), nil, nil, Config{})
	if n := p.PollDue(context.Background(), now); n != 0 {
		t.Fatalf("n = %d", n)
	}
	// Both feeds were attempted: one dead feed does not abort the round.
	if p.nextDue["live"].IsZero() {
		t.Fatal("the second feed was never attempted")
	}
}

func TestNoFeedsMeansNoPoller(t *testing.T) {
	if p := NewPoller(nil, &fakeFetch{}, &fakeNotes{}, nil, nil, nil, Config{}); p != nil {
		t.Fatal("NewPoller must return nil when nothing is configured")
	}
	if p := NewPoller([]FeedConfig{{Name: "", URL: ""}}, &fakeFetch{}, &fakeNotes{}, nil, nil, nil, Config{}); p != nil {
		t.Fatal("a feed with no name or url is not a configuration")
	}
}
