package points_test

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

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

func service(t *testing.T, in ...store.IncomingPoint) (*points.Service, *store.Store) {
	t.Helper()

	st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
	if err != nil {
		t.Fatalf("store.Open: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })

	if len(in) > 0 {
		if _, err := st.Merge(context.Background(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil {
			t.Fatalf("слияние: %v", err)
		}
	}
	return points.New(st, slog.New(slog.DiscardHandler)), st
}

func hourAt(hh int) time.Time { return time.Date(2026, 6, 1, hh, 0, 0, 0, time.UTC) }

func in(metric, layer string, at time.Time) store.IncomingPoint {
	return store.IncomingPoint{
		Metric: metric, Layer: layer, Units: "count",
		Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
	}
}

// Версия ответа несёт ГОРИЗОНТ измерения, а не только версию витрины.
//
// Без горизонта метка не меняется, когда час из будущего въезжает в окно сам,
// ходом часов и без единого коммита, — и соседняя задача условного запроса
// подтвердит `304` на ответе, чей род уже перевернулся. Путь проект уже строил
// и закрывал у каталога; здесь он закрывается тем же механизмом.
func TestВерсияОтветаНесётГоризонт(t *testing.T) {
	svc, st := service(t, in("m", "raw", hourAt(9)))
	ctx := context.Background()

	got, err := svc.Series(ctx, points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)})
	if err != nil {
		t.Fatalf("Series: %v", err)
	}
	if got.Version == "" {
		t.Fatal("ответ без версии — подписывать условный запрос нечем")
	}

	bare, err := st.StateVersion(ctx)
	if err != nil {
		t.Fatalf("StateVersion: %v", err)
	}
	if got.Version == bare {
		t.Error("версия ответа равна версии витрины — горизонт в неё не вошёл")
	}
	if want := catalog.Stamp(bare, catalog.Horizon()); got.Version != want {
		t.Errorf("версия ответа %q, ожидалась %q", got.Version, want)
	}
}

// Слой выбирается тем же правилом, что проверено на охватах, но уже через
// хранилище: «вес за год» обязан вернуть длинный слой, а не плотный короткий.
func TestРядБерётСлойСНаибольшимОхватом(t *testing.T) {
	svc, _ := service(t,
		in("body_mass", "raw", hourAt(9)),
		in("body_mass", "raw", hourAt(10)),
		in("body_mass", "hour", hourAt(1)),
		in("body_mass", "hour", hourAt(20)),
	)

	got, err := svc.Series(context.Background(), points.Request{
		Metric: "body_mass", From: hourAt(0), To: hourAt(23),
	})
	if err != nil {
		t.Fatalf("Series: %v", err)
	}
	if got.Layer != "hour" {
		t.Errorf("слой %q, ожидался hour: нижний слой охватывает меньше", got.Layer)
	}
	if len(got.Points) != 2 {
		t.Errorf("точек %d, ожидалось 2", len(got.Points))
	}
}

// Явно запрошенный слой уезжает в ответе даже пустым: клиент, спросивший разрез
// поимённо, обязан отличать «за период этого разреза нет» от «параметр
// проигнорирован». Слой, который выбирала система и выбрать не смогла, — пустой.
func TestРядРазличаетПустойЯвныйСлойИОтсутствиеВыбора(t *testing.T) {
	svc, _ := service(t, in("m", "raw", hourAt(9)))
	ctx := context.Background()

	explicit, err := svc.Series(ctx, points.Request{
		Metric: "m", From: hourAt(0), To: hourAt(23), Layer: "minute",
	})
	if err != nil {
		t.Fatalf("Series: %v", err)
	}
	if explicit.Layer != "minute" {
		t.Errorf("явный слой %q, ожидался minute", explicit.Layer)
	}
	if len(explicit.Points) != 0 {
		t.Errorf("точек %d, ожидалось 0", len(explicit.Points))
	}

	chosen, err := svc.Series(ctx, points.Request{
		Metric: "нет такой", From: hourAt(0), To: hourAt(23),
	})
	if err != nil {
		t.Fatalf("Series: %v", err)
	}
	if chosen.Layer != "" {
		t.Errorf("слой %q, ожидался пустой: выбирать было не из чего", chosen.Layer)
	}
}

// Отмена запроса клиентом — обстоятельство, а не отказ: ответ не собирается, но
// и ERROR владельцу не пишется. Уровень проверяет тест транспорта; здесь —
// что отмена вообще доезжает до драйвера и не игнорируется.
func TestРядУважаетОтменуКонтекста(t *testing.T) {
	svc, _ := service(t, in("m", "raw", hourAt(9)))

	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	if _, err := svc.Series(ctx, points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}); err == nil {
		t.Error("отменённый запрос собрал ответ — context до драйвера не доехал")
	}
}

// Отказ хранилища доезжает до вызывающего отказом, а не пустым рядом: маршрут
// обязан ответить 500, а не «данных нет». Отказ при этом НЕ транзиентный —
// значит уходит владельцу уровнем ERROR, а не тонет в DEBUG.
func TestРядНаЗакрытомХранилищеОтказывает(t *testing.T) {
	svc, st := service(t, in("m", "raw", hourAt(9)))
	if err := st.Close(); err != nil {
		t.Fatalf("закрытие: %v", err)
	}

	_, err := svc.Series(context.Background(), points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)})
	if err == nil {
		t.Fatal("закрытое хранилище отдало ряд")
	}
	if store.Transient(err) {
		t.Error("отказ закрытого хранилища объявлен обстоятельством — владелец о нём не узнает")
	}
}

// levels — slog.Handler, копящий уровень и сообщение. Значений атрибутов не
// хранит: проверяется адресат записи, а данные о здоровье в тесты тащить
// незачем.
type levels struct {
	mu   sync.Mutex
	seen []slog.Record
}

func (l *levels) Enabled(context.Context, slog.Level) bool { return true }
func (l *levels) WithAttrs([]slog.Attr) slog.Handler       { return l }
func (l *levels) WithGroup(string) slog.Handler            { return l }

func (l *levels) Handle(_ context.Context, r slog.Record) error {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.seen = append(l.seen, r.Clone())
	return nil
}

func (l *levels) levelOf(msg string) (slog.Level, bool) {
	l.mu.Lock()
	defer l.mu.Unlock()
	for _, r := range l.seen {
		if r.Message == msg {
			return r.Level, true
		}
	}
	return 0, false
}

func (l *levels) count(level slog.Level) int {
	l.mu.Lock()
	defer l.mu.Unlock()
	n := 0
	for _, r := range l.seen {
		if r.Level == level {
			n++
		}
	}
	return n
}

func loggedService(t *testing.T, in ...store.IncomingPoint) (*points.Service, *store.Store, *levels) {
	t.Helper()

	st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
	if err != nil {
		t.Fatalf("store.Open: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })
	if len(in) > 0 {
		if _, err := st.Merge(context.Background(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil {
			t.Fatalf("слияние: %v", err)
		}
	}

	seen := &levels{}
	return points.New(st, slog.New(seen)), st, seen
}

// Отмена клиентом — обстоятельство, а не отказ, и уровень записи это отражает.
//
// Утверждение прямое, потому что иначе оно не держится ничем: смена
// классификации не даёт ни ошибки компиляции, ни красного теста. Агент
// опрашивает маршрут по расписанию, и `ERROR` на каждый его тайм-аут забил бы
// единственный канал, по которому владелец видит настоящий сбой хранилища.
func TestОтменаЗапросаПишетсяDEBUG(t *testing.T) {
	svc, _, seen := loggedService(t, in("m", "raw", hourAt(9)))

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	if _, err := svc.Series(ctx, points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}); err == nil {
		t.Fatal("отменённый запрос собрал ответ")
	}

	level, ok := seen.levelOf("series interrupted")
	if !ok {
		t.Fatal("отмена не оставила чекпоинта — исход не наблюдаем")
	}
	if level != slog.LevelDebug {
		t.Errorf("уровень %s, ожидался DEBUG", level)
	}
	if n := seen.count(slog.LevelError); n != 0 {
		t.Errorf("записей ERROR %d, ожидалось 0: отмена клиента — не сбой хранилища", n)
	}
}

// Настоящий отказ хранилища доходит до владельца уровнем ERROR.
func TestОтказХранилищаПишетсяERROR(t *testing.T) {
	svc, st, seen := loggedService(t, in("m", "raw", hourAt(9)))
	if err := st.Close(); err != nil {
		t.Fatalf("закрытие: %v", err)
	}

	if _, err := svc.Series(context.Background(), points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}); err == nil {
		t.Fatal("закрытое хранилище собрало ответ")
	}
	level, ok := seen.levelOf("series failed")
	if !ok {
		t.Fatal("отказ не оставил чекпоинта")
	}
	if level != slog.LevelError {
		t.Errorf("уровень %s, ожидался ERROR", level)
	}
}

// Предупреждения измерения — привилегия каталога, и маршрут точек их НЕ
// повторяет: агент опрашивает по расписанию, и WARN на каждый опрос обесценил
// бы уровень ровно так же, как обесценила бы его строка на каждый `304`.
func TestМаршрутТочекНеПовторяетПредупрежденияИзмерения(t *testing.T) {
	// Метрика с противоречащим родом: часть часов сходится с суммой, часть — со
	// средним. Каталог на таком входе пишет WARN.
	var seed []store.IncomingPoint
	for h := range 8 {
		hour := hourAt(h)
		coarse := "30"
		if h%2 == 0 {
			coarse = "15" // среднее двух минутных значений 10 и 20
		}
		seed = append(seed,
			store.IncomingPoint{Metric: "mixed", Layer: "hour", Units: "kJ",
				Point: store.Point{Start: hour, End: hour, Raw: json.RawMessage(`{"qty":` + coarse + `}`)}},
			store.IncomingPoint{Metric: "mixed", Layer: "minute", Units: "kJ",
				Point: store.Point{Start: hour, End: hour, Raw: json.RawMessage(`{"qty":10}`)}},
			store.IncomingPoint{Metric: "mixed", Layer: "minute", Units: "kJ",
				Point: store.Point{Start: hour.Add(time.Minute), End: hour.Add(time.Minute), Raw: json.RawMessage(`{"qty":20}`)}},
		)
	}
	svc, _, seen := loggedService(t, seed...)

	for range 3 {
		if _, err := svc.Series(context.Background(), points.Request{
			Metric: "mixed", From: hourAt(0), To: hourAt(23),
		}); err != nil {
			t.Fatalf("Series: %v", err)
		}
	}
	if n := seen.count(slog.LevelWarn); n != 0 {
		t.Errorf("маршрут точек написал %d предупреждений — опрос по расписанию обесценит уровень", n)
	}
}
