package main

import (
	"context"
	"errors"
	"log/slog"
	"time"

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

// checkpointInterval — как часто разбирается журнал WAL.
//
// Автоматический чекпойнт SQLite остаётся первой линией и срабатывает по концу
// записи; этот тик закрывает случай, которого тот не закрывает по построению —
// запись прекратилась, а журнал остался неразобранным. Поток пачечный, ночью
// телефон молчит часами, поэтому минута против пяти неразличима по эффекту;
// минута взята потому, что с ней своевременен признак «журнал не разбирается»,
// и потому, что это тот же ритм, что у тика воркера свёртки. Тот же период
// берёт Litestream, у которого задача ровно та же.
const checkpointInterval = time.Minute

// walGrowth — во сколько раз обязан вырасти неразобранный журнал, чтобы о нём
// сказали второй раз.
//
// Признак заводится ради состояния, которое САМО НЕ ПРОХОДИТ: вечный читатель
// (в Go чаще всего — незакрытый `sql.Rows`) держит снимок до конца жизни
// процесса. Строка на каждый тик дала бы 1440 одинаковых `WARN` в сутки, и
// владелец перестал бы их читать раньше, чем кончится диск. Поэтому вторая
// строка пишется, только когда стало вдвое хуже.
const walGrowth = 2

// keepWAL разбирает журнал WAL, пока сервис работает.
//
// Живёт в бинаре, а не в хранилище, и это осознанная асимметрия с воркером
// свёртки: у воркера есть доменный исход (доставка свёрнута), а здесь только
// жизненный цикл процесса и строка владельцу. Чтобы завести цикл в `store`,
// пришлось бы внести туда логгер — первый в пакете, который сегодня не логирует
// вовсе и все исходы отдаёт возвратом. Интерпретация чисел при этом осталась в
// хранилище (`Checkpoint.Stuck`): семантика тройки `busy/log/checkpointed`
// принадлежит SQLite, а не тому, кто её печатает.
//
// Период параметром, а не константой внутри: тот же шов, что `Worker.Pass` у
// свёртки, и по той же причине — иначе проверка «цикл переживает отказ» ждала
// бы по минуте на тик. Конфигурируемостью это не является: вызов один, и он
// называет константу.
//
// Контекст один, и работа идёт на нём же — в отличие от свёртки, которая
// сворачивает на отвязанном. Прерванный чекпойнт ничего не теряет: перенос
// страниц идемпотентен, исхода разбора он не пишет, а следующий старт возьмёт
// журнал с того же места. Зато остановка не ждёт переноса полусотни мегабайт в
// бюджете, который делится с приёмом и воркером.
func keepWAL(ctx context.Context, st *store.Store, log *slog.Logger, every time.Duration) {
	log = log.With("capability", "wal")
	ticker := time.NewTicker(every)
	defer ticker.Stop()

	var watch walWatch
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
		}

		ck, err := st.CheckpointWAL(ctx)
		if err != nil {
			if errors.Is(err, context.Canceled) {
				// Штатная остановка не отказ: чекпойнт прерван ею же. ERROR о
				// ней обесценил бы уровень, по которому вмешиваются, — и делал
				// бы это на каждом `task restart`.
				//
				// Различаем по САМОЙ ошибке, а не по `ctx.Err()`: настоящий
				// отказ базы, случившийся в тот же тик, что и сигнал остановки,
				// иначе подавлялся бы как штатный — то есть терялся бы ровно
				// тогда, когда владелец смотрит в логи.
				return
			}
			// Отказ не прекращает цикл: обслуживание, умершее от временного
			// отказа базы, молча перестало бы разбирать журнал до конца жизни
			// процесса — а видно это было бы только по свободному месту.
			log.ErrorContext(ctx, "wal checkpoint failed", "error", err)
			continue
		}

		switch watch.see(ck) {
		case walStuck:
			// Адресат — владелец, событие «может стать проблемой»: журнал
			// растёт, и лечится это не кодом. Значений из данных в записи нет —
			// только счётчики страниц.
			log.WarnContext(ctx, "wal checkpoint did not advance",
				"log_pages", ck.Log,
				"checkpointed_pages", ck.Checkpointed)
		case walRecovered:
			// Возврат к норме — событие, и сказать о нём надо: молчание иначе
			// неотличимо от «сервис перестал проверять».
			log.InfoContext(ctx, "wal checkpoint caught up", "log_pages", ck.Log)
		}
	}
}

// walSay — что сказать владельцу по исходу очередного чекпойнта.
type walSay int

const (
	walSilent walSay = iota
	walStuck
	walRecovered
)

// walWatch решает, когда о неразобранном журнале говорить. Отдельно от цикла,
// потому что это единственная его часть, у которой есть исход: решение зависит
// от предыдущих тиков, а проверять его ожиданием минут нельзя.
type walWatch struct {
	// warnedAt — размер журнала, о котором уже сказано. Ноль означает
	// «состояние нормальное». Свойство разговора с владельцем, а не базы,
	// поэтому живёт здесь, а не в хранилище.
	warnedAt int
}

func (w *walWatch) see(ck store.Checkpoint) walSay {
	switch {
	case !ck.Known():
		// Исход не измерен (чекпойнт не взял блокировку). Молчим и НЕ трогаем
		// накопленное: иначе занятый тик посреди беды прочитался бы как
		// выздоровление, сбросил бы подавитель и вернул те самые 1440 строк в
		// сутки, против которых он заведён.
		return walSilent
	case ck.Stuck() && (w.warnedAt == 0 || ck.Log >= w.warnedAt*walGrowth):
		w.warnedAt = ck.Log
		return walStuck
	case ck.Complete() && w.warnedAt != 0:
		// Именно `Complete`, а не «порог перестал срабатывать»: журнал, упавший
		// ниже порога, но так и не перенесённый, — это всё ещё удерживаемый
		// снимок. Строка «догнали» при нуле перенесённых страниц утверждала бы
		// то, чего никто не проверял.
		w.warnedAt = 0
		return walRecovered
	default:
		return walSilent
	}
}
