package storage

import (
	"path/filepath"
	"testing"
)

func setupTestDB(t *testing.T) {
	t.Helper()

	// Reset global state
	mu.Lock()
	if globalDB != nil {
		globalDB.Close()
		globalDB = nil
	}
	mu.Unlock()

	dbPath := filepath.Join(t.TempDir(), "test.db")
	if err := Init(dbPath); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { Close() })
}

func TestInitAndGet(t *testing.T) {
	setupTestDB(t)
	db := Get()
	if db == nil {
		t.Fatal("Get() returned nil after Init()")
	}
}

func TestIsGUIDSeen_NotSeen(t *testing.T) {
	setupTestDB(t)
	if IsGUIDSeen("never-seen-guid") {
		t.Error("expected false for unseen GUID")
	}
}

func TestInsertStoryAndGUIDSeen(t *testing.T) {
	setupTestDB(t)

	s := &Story{
		GUID:       "test-guid-1",
		Headline:   "Test Headline",
		Lede:       "Test lede sentence.",
		ArticleURL: "https://example.com/article",
		Source:     "Test Source",
		SeenAt:     1700000000,
	}
	if err := InsertStory(s); err != nil {
		t.Fatal(err)
	}

	if !IsGUIDSeen("test-guid-1") {
		t.Error("expected true after insert")
	}
	if IsGUIDSeen("other-guid") {
		t.Error("expected false for different GUID")
	}
}

func TestGetStoryReaderText(t *testing.T) {
	setupTestDB(t)

	// Reader text is only served for classified, non-sentinel stories — the same
	// filter the public /api/article endpoint applies so discarded/unclassified
	// bodies can't be pulled by id enumeration.
	s := &Story{
		GUID:       "reader-guid",
		Headline:   "Reader Headline",
		Lede:       "Short lede.",
		Content:    "First paragraph.\n\nSecond paragraph.",
		ArticleURL: "https://example.com/reader",
		Source:     "Src",
		Channel:    "tech",
		Classified: true,
		SeenAt:     1700000000,
	}
	if err := InsertStory(s); err != nil {
		t.Fatal(err)
	}
	var id int64
	if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, s.GUID).Scan(&id); err != nil {
		t.Fatal(err)
	}

	content, lede, found, err := GetStoryReaderText(id)
	if err != nil {
		t.Fatal(err)
	}
	if !found {
		t.Fatal("expected story to be found")
	}
	if content != s.Content {
		t.Errorf("content = %q, want %q", content, s.Content)
	}
	if lede != s.Lede {
		t.Errorf("lede = %q, want %q", lede, s.Lede)
	}

	// A story ingested before content capture (no content) is still found, with
	// an empty content string so the reader falls back to the lede.
	bare := &Story{GUID: "bare-guid", Headline: "H", Lede: "Only a lede.", ArticleURL: "https://a.com", Source: "S", Channel: "tech", Classified: true, SeenAt: 1}
	if err := InsertStory(bare); err != nil {
		t.Fatal(err)
	}
	var bareID int64
	if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, bare.GUID).Scan(&bareID); err != nil {
		t.Fatal(err)
	}
	content, lede, found, err = GetStoryReaderText(bareID)
	if err != nil || !found {
		t.Fatalf("bare story: found=%v err=%v", found, err)
	}
	if content != "" {
		t.Errorf("bare content = %q, want empty", content)
	}
	if lede != "Only a lede." {
		t.Errorf("bare lede = %q", lede)
	}

	// Unknown id reports not-found rather than erroring.
	if _, _, found, err = GetStoryReaderText(999999); err != nil || found {
		t.Errorf("unknown id: found=%v err=%v, want found=false err=nil", found, err)
	}

	// A discarded story must not be readable via /api/article, even by direct id.
	discarded := &Story{GUID: "disc-guid", Headline: "H", Lede: "hidden", Content: "secret body", ArticleURL: "https://d.com", Source: "S", Channel: "_discarded", Classified: true, SeenAt: 1}
	if err := InsertStory(discarded); err != nil {
		t.Fatal(err)
	}
	var discID int64
	if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, discarded.GUID).Scan(&discID); err != nil {
		t.Fatal(err)
	}
	if _, _, found, err = GetStoryReaderText(discID); err != nil || found {
		t.Errorf("discarded story: found=%v err=%v, want found=false", found, err)
	}
}

func TestInsertStoryDuplicateGUID(t *testing.T) {
	setupTestDB(t)

	s := &Story{
		GUID: "dup-guid", Headline: "H1", ArticleURL: "https://a.com", Source: "S", SeenAt: 1,
	}
	if err := InsertStory(s); err != nil {
		t.Fatal(err)
	}
	// Second insert with same GUID should fail
	err := InsertStory(s)
	if err == nil {
		t.Error("expected error on duplicate GUID insert")
	}
}

func TestMarkClassified(t *testing.T) {
	setupTestDB(t)

	InsertStory(&Story{
		GUID: "g1", Headline: "H1", ArticleURL: "https://a.com", Source: "S", SeenAt: 1,
	})

	MarkClassified("g1", "tech", "[]")

	var channel string
	var classified int
	Get().QueryRow(`SELECT channel, classified FROM stories WHERE guid = ?`, "g1").
		Scan(&channel, &classified)
	if channel != "tech" || classified != 1 {
		t.Errorf("after MarkClassified: channel=%q classified=%d, want tech/1", channel, classified)
	}
}

func TestPostLogAndLookup(t *testing.T) {
	setupTestDB(t)

	InsertPostLog("story-1", "tech", "$event1:example.org", "", false)
	InsertPostLog("story-2", "politics", "$event2:example.org", "", false)

	guid, channel, found := LookupPostGUID("$event1:example.org")
	if !found {
		t.Fatal("expected to find event")
	}
	if guid != "story-1" || channel != "tech" {
		t.Errorf("got guid=%q channel=%q", guid, channel)
	}

	_, _, found = LookupPostGUID("$nonexistent:example.org")
	if found {
		t.Error("expected not found for unknown event")
	}
}

func TestLastPostTimeAndCountInWindow(t *testing.T) {
	setupTestDB(t)

	if got := GetLastPostTime("tech"); got != 0 {
		t.Errorf("empty channel last post = %d, want 0", got)
	}

	// Insert posts with specific timestamps
	Get().Exec(`INSERT INTO post_log (guid, channel, event_id, posted_at) VALUES (?, ?, ?, ?)`,
		"g1", "tech", "$e1", 1000)
	Get().Exec(`INSERT INTO post_log (guid, channel, event_id, posted_at) VALUES (?, ?, ?, ?)`,
		"g2", "tech", "$e2", 2000)
	Get().Exec(`INSERT INTO post_log (guid, channel, event_id, posted_at) VALUES (?, ?, ?, ?)`,
		"g3", "politics", "$e3", 1500)

	if got := GetLastPostTime("tech"); got != 2000 {
		t.Errorf("tech last post = %d, want 2000", got)
	}

	count := CountPostsInWindow("tech", 500)
	if count != 2 {
		t.Errorf("tech posts in window = %d, want 2", count)
	}

	count = CountPostsInWindow("tech", 1500)
	if count != 1 {
		t.Errorf("tech posts after 1500 = %d, want 1", count)
	}

	count = CountPostsInWindow("politics", 0)
	if count != 1 {
		t.Errorf("politics posts = %d, want 1", count)
	}
}

func TestReactions(t *testing.T) {
	setupTestDB(t)

	InsertReaction("story-1", "tech", "$react1", "📰", "@user:example.org", 1000)
	InsertReaction("story-1", "tech", "$react2", "👍", "@user2:example.org", 1001)

	var count int
	Get().QueryRow(`SELECT COUNT(*) FROM reactions WHERE post_guid = ?`, "story-1").Scan(&count)
	if count != 2 {
		t.Errorf("reactions = %d, want 2", count)
	}
}

func TestMarshalUnmarshalPlatforms(t *testing.T) {
	tests := []struct {
		name  string
		input []string
		json  string
		back  []string
	}{
		{"nil", nil, "[]", nil},
		{"empty", []string{}, "[]", nil},
		{"single", []string{"pc"}, `["pc"]`, []string{"pc"}},
		{"multi", []string{"nintendo-switch", "pc"}, `["nintendo-switch","pc"]`, []string{"nintendo-switch", "pc"}},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := MarshalPlatforms(tt.input)
			if got != tt.json {
				t.Errorf("Marshal(%v) = %q, want %q", tt.input, got, tt.json)
			}
			back := UnmarshalPlatforms(got)
			if len(back) != len(tt.back) {
				t.Errorf("Unmarshal roundtrip: %v, want %v", back, tt.back)
			}
		})
	}
}

func TestInsertPostLog_DuplicateIgnored(t *testing.T) {
	setupTestDB(t)

	InsertPostLog("story-1", "tech", "$event1:example.org", "", false)
	// Same guid+channel should be silently ignored
	InsertPostLog("story-1", "tech", "$event1-retry:example.org", "", false)

	var count int
	Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE guid = ? AND channel = ?`, "story-1", "tech").Scan(&count)
	if count != 1 {
		t.Errorf("expected 1 post_log entry, got %d", count)
	}

	// Different channel should be allowed
	InsertPostLog("story-1", "politics", "$event2:example.org", "", false)
	Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE guid = ?`, "story-1").Scan(&count)
	if count != 2 {
		t.Errorf("expected 2 post_log entries across channels, got %d", count)
	}
}

func TestInsertReaction_DuplicateIgnored(t *testing.T) {
	setupTestDB(t)

	InsertReaction("story-1", "tech", "$react1", "📰", "@user:example.org", 1000)
	// Same event_id should be silently ignored
	InsertReaction("story-1", "tech", "$react1", "📰", "@user:example.org", 1001)

	var count int
	Get().QueryRow(`SELECT COUNT(*) FROM reactions WHERE event_id = ?`, "$react1").Scan(&count)
	if count != 1 {
		t.Errorf("expected 1 reaction, got %d", count)
	}
}

func TestRunMaintenance(t *testing.T) {
	setupTestDB(t)
	db := Get()

	now := nowUnix()
	old := now - 100*86400 // 100 days ago

	// Insert old + recent stories
	InsertStory(&Story{GUID: "old", Headline: "Old", ArticleURL: "https://a.com", Source: "S", Classified: true, SeenAt: old})
	InsertStory(&Story{GUID: "new", Headline: "New", ArticleURL: "https://b.com", Source: "S", Classified: true, SeenAt: now})
	MarkClassified("old", "tech", "[]")
	MarkClassified("new", "tech", "[]")

	// Insert old + recent post logs
	db.Exec(`INSERT INTO post_log (guid, channel, event_id, posted_at) VALUES (?, ?, ?, ?)`, "old", "tech", "$e1", old)
	db.Exec(`INSERT INTO post_log (guid, channel, event_id, posted_at) VALUES (?, ?, ?, ?)`, "new", "tech", "$e2", now)

	// Insert old + recent reactions
	InsertReaction("old", "tech", "$r1", "👍", "@u:x", old)
	InsertReaction("new", "tech", "$r2", "👍", "@u:x", now)

	RunMaintenance()

	// Old stories should be pruned
	var count int
	db.QueryRow(`SELECT COUNT(*) FROM stories`).Scan(&count)
	if count != 1 {
		t.Errorf("stories: expected 1, got %d", count)
	}

	db.QueryRow(`SELECT COUNT(*) FROM post_log`).Scan(&count)
	if count != 1 {
		t.Errorf("post_log: expected 1, got %d", count)
	}

	db.QueryRow(`SELECT COUNT(*) FROM reactions`).Scan(&count)
	if count != 1 {
		t.Errorf("reactions: expected 1, got %d", count)
	}
}

func TestFTS5Search(t *testing.T) {
	setupTestDB(t)

	InsertStory(&Story{
		GUID: "g1", Headline: "Elden Ring DLC breaks sales records", Lede: "FromSoftware celebrates milestone.",
		ArticleURL: "https://a.com", Source: "S", SeenAt: 1,
	})
	InsertStory(&Story{
		GUID: "g2", Headline: "Apple unveils new MacBook Pro", Lede: "M5 chip delivers impressive gains.",
		ArticleURL: "https://b.com", Source: "S", SeenAt: 2,
	})

	// Search for Elden Ring
	rows, err := Get().Query(`
		SELECT s.guid, s.headline FROM stories s
		JOIN stories_fts f ON s.id = f.rowid
		WHERE stories_fts MATCH 'Elden Ring'
		ORDER BY rank`)
	if err != nil {
		t.Fatal(err)
	}
	defer rows.Close()

	var results []string
	for rows.Next() {
		var guid, headline string
		rows.Scan(&guid, &headline)
		results = append(results, guid)
	}
	if len(results) != 1 || results[0] != "g1" {
		t.Errorf("FTS5 search results = %v, want [g1]", results)
	}
}
