package main

import (
	"context"
	"log/slog"
	"path/filepath"
	"sync"
	"testing"
	"time"

	"git.vakhrushev.me/av/healthlog/internal/store"
)

// Решение «сказать ли владельцу» проверяется таблицей, а не ожиданием минут:
// состояние копится по тикам, и без отдельной точки его пришлось бы проверять
// прогоном цикла.
func TestКогдаГоворитьОНеразобранномЖурнале(t *testing.T) {
	t.Parallel()

	const over = 100000 // заведомо больше порога, выраженного в страницах
	stuck := store.Checkpoint{Log: over, Checkpointed: 0, PageSize: 4096}
	worse := store.Checkpoint{Log: over * 4, Checkpointed: 0, PageSize: 4096}
	slightlyWorse := store.Checkpoint{Log: over + 1, Checkpointed: 0, PageSize: 4096}
	fine := store.Checkpoint{Log: 12, Checkpointed: 12, PageSize: 4096}
	// Занятый чекпойнт: исход не измерен, `-1` вместо чисел.
	unknown := store.Checkpoint{Busy: true, Log: -1, Checkpointed: -1, PageSize: 4096}

	var w walWatch
	cases := []struct {
		name string
		in   store.Checkpoint
		want walSay
	}{
		{"первый застрявший чекпойнт", stuck, walStuck},
		{"то же состояние — молчим", stuck, walSilent},
		{"чуть хуже — всё ещё молчим", slightlyWorse, walSilent},
		{"занятый тик посреди беды молчит", unknown, walSilent},
		{"и не сбрасывает накопленное", stuck, walSilent},
		{"стало заметно хуже", worse, walStuck},
		{"разобрался — говорим о возврате", fine, walRecovered},
		{"норма держится — молчим", fine, walSilent},
		{"застрял снова", stuck, walStuck},
	}
	for _, c := range cases {
		if got := w.see(c.in); got != c.want {
			t.Errorf("%s: сказано %v, ждали %v", c.name, got, c.want)
		}
	}
}

// Цикл обязан пережить отказ базы: обслуживание, умершее от временного отказа,
// молча перестало бы разбирать журнал до конца жизни процесса.
func TestЦиклЧекпойнтаПереживаетОтказ(t *testing.T) {
	t.Parallel()

	st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
	if err != nil {
		t.Fatalf("открытие базы: %v", err)
	}
	// Закрытая база — самый простой источник устойчивого отказа чекпойнта.
	if err := st.Close(); err != nil {
		t.Fatalf("закрытие базы: %v", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	seen := &lines{}
	done := make(chan struct{})
	go func() {
		defer close(done)
		keepWAL(ctx, st, slog.New(seen), time.Millisecond)
	}()

	// Даём циклу натолкнуться на отказ много раз подряд.
	time.Sleep(50 * time.Millisecond)
	select {
	case <-done:
		t.Fatal("цикл вышел сам, не дождавшись отмены")
	default:
	}
	// Отказ обязан быть виден: молча не разбирающийся журнал обнаруживается
	// только по свободному месту.
	if !seen.has("wal checkpoint failed") {
		t.Error("отказ чекпойнта не оставил записи владельцу")
	}

	cancel()
	select {
	case <-done:
	case <-time.After(5 * time.Second):
		t.Fatal("цикл не вышел по отмене")
	}
}

// Отмена — единственный законный повод выйти, и выйти надо сразу: горутина
// ждётся в общем бюджете остановки вместе с воркером свёртки.
func TestЦиклЧекпойнтаВыходитПоОтмене(t *testing.T) {
	t.Parallel()

	st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
	if err != nil {
		t.Fatalf("открытие базы: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })

	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})
	go func() {
		defer close(done)
		keepWAL(ctx, st, slog.New(slog.DiscardHandler), time.Millisecond)
	}()

	cancel()
	select {
	case <-done:
	case <-time.After(5 * time.Second):
		t.Fatal("цикл не вышел по отмене")
	}
}

// Ветка «фоновые горутины не уложились в бюджет» — последняя защита инварианта
// «доставка либо свёрнута целиком, либо остаётся pending». Прогоном сервиса её
// не проверить: бюджет тридцать секунд, а заставить воркер зависнуть нечем.
func TestОжиданиеФоновыхГорутин(t *testing.T) {
	t.Parallel()

	closed := make(chan struct{})
	close(closed)
	if !waitBackground(context.Background(), closed, slog.New(slog.DiscardHandler)) {
		t.Error("вышедшие горутины не дождались")
	}

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	seen := &lines{}
	if waitBackground(ctx, make(chan struct{}), slog.New(seen)) {
		t.Error("зависшие горутины объявлены вышедшими — база закрылась бы из-под них")
	}
	if !seen.has("shutdown budget exceeded") {
		t.Error("превышение бюджета осталось без строки владельцу")
	}
}

// lines — slog.Handler, копящий сообщения: проверяется факт записи, не данные.
type lines struct {
	mu  sync.Mutex
	msg []string
}

func (l *lines) Enabled(context.Context, slog.Level) bool { return true }

func (l *lines) Handle(_ context.Context, rec slog.Record) error {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.msg = append(l.msg, rec.Message)
	return nil
}

func (l *lines) WithAttrs([]slog.Attr) slog.Handler { return l }
func (l *lines) WithGroup(string) slog.Handler      { return l }

func (l *lines) has(msg string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	for _, m := range l.msg {
		if m == msg {
			return true
		}
	}
	return false
}
