package store_test

import (
	"context"
	"database/sql"
	"path/filepath"
	"testing"
	"time"

	_ "modernc.org/sqlite" // второе подключение к тому же файлу мимо store

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

func checkpoint(t *testing.T, st *store.Store) store.Checkpoint {
	t.Helper()

	ck, err := st.CheckpointWAL(t.Context())
	if err != nil {
		t.Fatalf("чекпойнт: %v", err)
	}
	return ck
}

// Штатный случай: писателей нет, читателей нет — журнал разбирается целиком.
// Это и есть работа, которой автоматический чекпойнт не делает: он срабатывает
// по концу записи, а ночью телефон молчит часами.
func TestЧекпойнтРазбираетЖурналВТишине(t *testing.T) {
	t.Parallel()

	st := open(t)
	base := ts(t, "2026-06-01T00:00:00Z")
	for i := range 20 {
		writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
	}

	ck := checkpoint(t, st)
	if !ck.Complete() {
		t.Errorf("журнал разобран не целиком: log=%d checkpointed=%d", ck.Log, ck.Checkpointed)
	}
	if ck.Stuck() {
		t.Errorf("разобранный журнал объявлен застрявшим: %+v", ck)
	}
}

// Щуп версии — единственное долгоживущее соединение процесса, и он ходит в базу
// дважды на каждый читающий запрос. Останься за ним открытая читающая
// транзакция — пассивный чекпойнт перестал бы продвигаться навсегда, и вторая
// половина задачи убила бы первую при полностью исправном обслуживании.
func TestЩупНеУдерживаетЧитающийСнимок(t *testing.T) {
	t.Parallel()

	st := open(t)
	base := ts(t, "2026-06-01T00:00:00Z")
	for i := range 10 {
		if _, err := st.StateVersion(t.Context()); err != nil {
			t.Fatalf("версия витрины: %v", err)
		}
		writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
	}

	ck := checkpoint(t, st)
	if !ck.Complete() {
		t.Errorf("щуп удерживает снимок: log=%d checkpointed=%d", ck.Log, ck.Checkpointed)
	}
}

// Удерживаемый читатель — ровно тот случай, ради которого признак и заведён:
// пассивный чекпойнт не идёт дальше его снимка и ОШИБКИ ПРИ ЭТОМ НЕ ВОЗВРАЩАЕТ.
// Проверяем, что признаком служит расхождение чисел, а не флаг занятости.
func TestЧекпойнтПодЧитателемНеПродвигаетсяБезОшибки(t *testing.T) {
	t.Parallel()

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

	base := ts(t, "2026-06-01T00:00:00Z")
	writePoint(t, st, "step_count", base)

	// Читающая транзакция мимо store: она моделирует не наш код, а любого
	// читателя, задержавшегося на снимке, — включая забытый `rows.Close()`.
	db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
	if err != nil {
		t.Fatalf("открытие второго подключения: %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })

	tx, err := db.BeginTx(t.Context(), &sql.TxOptions{ReadOnly: true})
	if err != nil {
		t.Fatalf("читающая транзакция: %v", err)
	}
	var n int
	if err := tx.QueryRowContext(t.Context(), "SELECT count(*) FROM bucket").Scan(&n); err != nil {
		t.Fatalf("чтение снимка: %v", err)
	}

	for i := 1; i < 40; i++ {
		writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
	}

	ck := checkpoint(t, st)
	_ = tx.Rollback()

	if ck.Busy {
		t.Errorf("флаг занятости взведён — признак строится не на нём: %+v", ck)
	}
	if ck.Complete() {
		// Не Skip: на платформе, разбирающей журнал под удерживаемым читателем,
		// ломается предпосылка всей задачи, а пропущенный тест выглядит зелёным
		// — и вместе с ним молча исчезает единственная защита режима PASSIVE.
		t.Fatalf("журнал разобран под удерживаемым читателем — предпосылка задачи не воспроизводится: %+v", ck)
	}
	if ck.Log <= ck.Checkpointed {
		t.Errorf("перенесено не меньше, чем лежит: %+v", ck)
	}
}

// Порог молчит на журнале обычного размера: иначе `WARN` шёл бы каждую минуту
// на здоровом сервисе, и уровень, по которому вмешиваются, перестал бы значить
// что-либо.
func TestНебольшойНеразобранныйЖурналНеЗастрял(t *testing.T) {
	t.Parallel()

	ck := store.Checkpoint{Log: 10, Checkpointed: 0, PageSize: 4096}
	if ck.Stuck() {
		t.Errorf("десять неразобранных страниц объявлены бедой: %+v", ck)
	}
	if ck.Complete() {
		t.Errorf("неразобранный журнал объявлен разобранным: %+v", ck)
	}
}

// Занятый чекпойнт отдаёт `busy=1` и `-1` вместо чисел: исход НЕ ИЗМЕРЕН.
// Внутриполосный `-1` опасен буквально — `-1 >= -1` истинно, то есть
// незамеренный тик читался бы как «журнал разобран целиком», и владельцу ушла
// бы строка о выздоровлении посреди болезни.
func TestЗанятыйЧекпойнтНеИзмерен(t *testing.T) {
	t.Parallel()

	ck := store.Checkpoint{Busy: true, Log: -1, Checkpointed: -1, PageSize: 4096}
	if ck.Known() {
		t.Errorf("занятый чекпойнт объявлен измеренным: %+v", ck)
	}
	if ck.Complete() {
		t.Errorf("незамеренный исход объявлен разобранным журналом: %+v", ck)
	}
	if ck.Stuck() {
		t.Errorf("незамеренный исход объявлен бедой: %+v", ck)
	}
}

// Журнала нет вовсе — SQLite отвечает теми же `-1`. Исход тот же: молчим.
func TestОтсутствующийЖурналНеИзмерен(t *testing.T) {
	t.Parallel()

	ck := store.Checkpoint{Log: -1, Checkpointed: -1, PageSize: 4096}
	if ck.Known() || ck.Complete() || ck.Stuck() {
		t.Errorf("исход без журнала прочитан как измеренный: %+v", ck)
	}
}

// Размер страницы — свойство файла, и без него порог не выразить. Неизвестен —
// признак молчит: сместившийся в разы порог хуже, чем его отсутствие.
func TestБезРазмераСтраницыПризнакМолчит(t *testing.T) {
	t.Parallel()

	ck := store.Checkpoint{Log: 1 << 20, Checkpointed: 0}
	if ck.Stuck() {
		t.Errorf("порог сработал при неизвестном размере страницы: %+v", ck)
	}
}

// Размер страницы приходит из базы, а не предполагается кодом.
func TestЧекпойнтНазываетРазмерСтраницы(t *testing.T) {
	t.Parallel()

	if got := checkpoint(t, open(t)).PageSize; got <= 0 {
		t.Errorf("размер страницы %d — порог выразить нечем", got)
	}
}

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

	st := open(t)
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	if _, err := st.CheckpointWAL(ctx); err == nil {
		t.Error("чекпойнт на отменённом контексте прошёл успешно")
	}
}
