package httpapi

import (
	"net/http"
	"strings"
	"testing"
	"time"

	"git.vakhrushev.me/av/jellybit/internal/store"
	"git.vakhrushev.me/av/jellybit/internal/worker"
)

// TestFmtDateZone — дата отображается в переданной таймзоне: полуночное UTC-время
// сдвигается на следующий день под Europe/Moscow (UTC+3), в UTC остаётся прежним.
func TestFmtDateZone(t *testing.T) {
	// 2026-06-14 22:30 UTC == 2026-06-15 01:30 MSK.
	ts := time.Date(2026, 6, 14, 22, 30, 0, 0, time.UTC)

	if got := fmtDate(ts, time.UTC); got != "2026-06-14" {
		t.Fatalf("UTC: got %q, want 2026-06-14", got)
	}
	msk, err := time.LoadLocation("Europe/Moscow")
	if err != nil {
		t.Fatalf("load Europe/Moscow (tzdata встроен): %v", err)
	}
	if got := fmtDate(ts, msk); got != "2026-06-15" {
		t.Fatalf("MSK: got %q, want 2026-06-15", got)
	}
}

func TestHumanizeAge(t *testing.T) {
	now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC)
	cases := []struct {
		ago  time.Duration
		want string
	}{
		{30 * time.Second, "только что"},
		{time.Minute, "1 минуту назад"},
		{5 * time.Minute, "5 минут назад"},
		{2 * time.Hour, "2 часа назад"},
		{24 * time.Hour, "1 день назад"},
		{5 * 24 * time.Hour, "5 дней назад"},
		{40 * 24 * time.Hour, "1 месяц назад"},
		{400 * 24 * time.Hour, "1 год назад"},
	}
	for _, c := range cases {
		if got := humanizeAge(now.Add(-c.ago), now); got != c.want {
			t.Errorf("humanizeAge(-%s) = %q, want %q", c.ago, got, c.want)
		}
	}
	// Будущее (рассинхрон часов) не должно давать «-N»: схлопывается в «только что».
	if got := humanizeAge(now.Add(time.Hour), now); got != "только что" {
		t.Errorf("humanizeAge(future) = %q, want «только что»", got)
	}
}

func TestPlural(t *testing.T) {
	cases := []struct {
		n    int
		want string
	}{{1, "день"}, {2, "дня"}, {4, "дня"}, {5, "дней"}, {11, "дней"}, {14, "дней"}, {21, "день"}, {22, "дня"}, {25, "дней"}}
	for _, c := range cases {
		if got := plural(c.n, "день", "дня", "дней"); got != c.want {
			t.Errorf("plural(%d) = %q, want %q", c.n, got, c.want)
		}
	}
}

func TestSizeAndRatioText(t *testing.T) {
	// Снимок есть → размер из total_size, рейтинг из снимка.
	l := worker.Live{TotalSize: 2 << 30, Ratio: 1.42}
	if got := sizeText(l, true, 0); got != fmtBytes(2<<30) {
		t.Errorf("size (снимок) = %q, want %q", got, fmtBytes(2<<30))
	}
	if got := ratioText(l, true); got != "1.42" {
		t.Errorf("ratio (снимок) = %q, want 1.42", got)
	}
	// Снимка нет → размер из фолбэка по файлам, рейтинг «—».
	if got := sizeText(worker.Live{}, false, 512); got != fmtBytes(512) {
		t.Errorf("size (фолбэк) = %q, want %q", got, fmtBytes(512))
	}
	if got := ratioText(worker.Live{}, false); got != "—" {
		t.Errorf("ratio (нет снимка) = %q, want «—»", got)
	}
	// Ни снимка, ни файлов → «—».
	if got := sizeText(worker.Live{}, false, 0); got != "—" {
		t.Errorf("size (нет данных) = %q, want «—»", got)
	}
}

func TestAddedTimeFallback(t *testing.T) {
	// source_added_at приоритетнее created_at.
	added := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC)
	created := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
	d := store.Download{
		CreatedAt:     store.FormatTime(created),
		SourceAddedAt: store.NullString(store.FormatTime(added)),
	}
	got, ok := addedTime(d)
	if !ok || !got.Equal(added) {
		t.Errorf("addedTime = %v (ok=%v), want %v", got, ok, added)
	}
	// Без source_added_at — фолбэк на created_at.
	d.SourceAddedAt = store.NullString("")
	got, ok = addedTime(d)
	if !ok || !got.Equal(created) {
		t.Errorf("addedTime (фолбэк) = %v (ok=%v), want %v", got, ok, created)
	}
	// Нечего парсить — ok=false.
	if _, ok := addedTime(store.Download{}); ok {
		t.Error("addedTime пустой должен вернуть ok=false")
	}
}

// TestIndexCardMeta: карточка списка несёт обзорную мета-строку (метка ID, дата
// добавления, размер и рейтинг из снимка) и НЕ показывает контекст.
func TestIndexCardMeta(t *testing.T) {
	added := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC)
	dl := store.Download{
		ID: testULID, SourceRef: "Dune", DisplayName: "Dune (2024)", Context: "секретный контекст",
		Infohashes:    []store.Infohash{{DownloadID: testULID, Infohash: "ihmeta", Kind: store.HashV1}},
		State:         store.StateDone,
		SourceAddedAt: store.NullString(store.FormatTime(added)),
	}
	lv := stubLive{m: map[string]worker.Live{"ihmeta": {Seeding: true, TotalSize: 2 << 30, Ratio: 1.42}}}
	h := testRouterLive(t, stubReader{list: []store.Download{dl}}, stubReviewer{}, lv)

	rr := get(t, h, "/")
	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rr.Code)
	}
	body := rr.Body.String()
	for _, want := range []string{"ID:", "2026-06-30", "назад", "1.42", fmtBytes(2 << 30)} {
		if !strings.Contains(body, want) {
			t.Errorf("карточка не содержит %q", want)
		}
	}
	// Контекст из карточки убран (доступен на /download/{id}).
	if strings.Contains(body, "секретный контекст") {
		t.Error("контекст всё ещё показан в карточке списка")
	}
}

// N2: shorten режет по рунам, не байтам — кириллица (2 байта/руна) не рвётся
// посреди символа в U+FFFD.
func TestShortenRuneSafe(t *testing.T) {
	// 50 кириллических рун (100 байт). Обрезка до 40 рун раньше резала s[:40]
	// посреди руны.
	s := strings.Repeat("я", 50)
	got := shorten(s, 40)
	if strings.ContainsRune(got, '�') {
		t.Errorf("обрезка порвала руну: %q", got)
	}
	// 40 рун + многоточие.
	if want := strings.Repeat("я", 40) + "…"; got != want {
		t.Errorf("shorten = %q, want %q", got, want)
	}
	// Короткая строка (по рунам) возвращается как есть, без многоточия.
	if got := shorten("привет", 40); got != "привет" {
		t.Errorf("короткая строка изменена: %q", got)
	}
}
