package catalog_test

import (
	"context"
	"fmt"
	"log/slog"
	"path/filepath"
	"strings"
	"testing"
	"time"

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

func openStore(t *testing.T) *store.Store {
	t.Helper()

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

func service(t *testing.T, st *store.Store) *catalog.Service {
	t.Helper()

	return catalog.New(st, slog.New(slog.DiscardHandler))
}

// logged собирает записи лога: чекпоинт, который никто не перехватывает, ничем
// не удерживается — его перевод на DEBUG или потеря атрибутов пройдут зелёным
// гейтом, а владелец о смене рода не узнает.
type logged struct {
	records []slog.Record
}

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

func (h *logged) Handle(_ context.Context, r slog.Record) error {
	h.records = append(h.records, r.Clone())
	return nil
}

func (h *logged) find(msg string) (slog.Record, bool) {
	for _, r := range h.records {
		if r.Message == msg {
			return r, true
		}
	}
	return slog.Record{}, false
}

func attr(r slog.Record, key string) string {
	out := ""
	r.Attrs(func(a slog.Attr) bool {
		if a.Key == key {
			out = a.Value.String()
			return false
		}
		return true
	})
	return out
}

func incoming(metric, layer, units string, at time.Time, v float64) store.IncomingPoint {
	return store.IncomingPoint{
		Metric: metric,
		Layer:  layer,
		Units:  units,
		Point:  store.Point{Start: at, End: at, Raw: qty(v)},
	}
}

func merge(t *testing.T, st *store.Store, points []store.IncomingPoint) {
	t.Helper()

	_, err := st.Merge(context.Background(), store.Incoming{Points: points},
		store.DeliveryRef{ID: "delivery"})
	if err != nil {
		t.Fatalf("слияние: %v", err)
	}
}

// метрику кладём в оба слоя за n часов: часовая точка на границе часа, три
// минутных внутри. `sum` задаёт, будет ли часовое значение суммой или средним.
func fill(t *testing.T, st *store.Store, metric string, hours int, sum bool) {
	t.Helper()

	var points []store.IncomingPoint
	for i := range hours {
		h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
		coarse := 2.0
		if sum {
			coarse = 6.0
		}
		points = append(points, incoming(metric, "hour", "count", h, coarse))
		for m, v := range []float64{1, 2, 3} {
			points = append(points, incoming(metric, "minute", "count", h.Add(time.Duration(m)*time.Minute), v))
		}
	}
	merge(t, st, points)
}

func find(t *testing.T, metrics []catalog.Metric, name string) catalog.Metric {
	t.Helper()

	for _, m := range metrics {
		if m.Metric == name {
			return m
		}
	}
	t.Fatalf("метрики %q в каталоге нет", name)
	return catalog.Metric{}
}

func TestКаталогОтдаётРазрезыИИзмеренныйРод(t *testing.T) {
	t.Parallel()

	st := openStore(t)
	fill(t, st, "step_count", 5, true)
	fill(t, st, "heart_rate", 5, false)
	// Метрика, лежащая только в нижнем слое: слой в каталоге есть, рода нет.
	merge(t, st, []store.IncomingPoint{
		incoming("sleep_analysis", "raw", "hr", time.Date(2026, 6, 1, 3, 7, 0, 0, time.UTC), 1),
	})

	metrics, err := metricsOf(context.Background(), service(t, st))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}

	steps := find(t, metrics, "step_count")
	if steps.Aggregation.Style != catalog.Cumulative {
		t.Errorf("step_count: род %v", steps.Aggregation.Style)
	}
	if len(steps.Units) != 1 || steps.Units[0] != "count" {
		t.Errorf("step_count: единицы %v", steps.Units)
	}
	if len(steps.Layers) != 2 {
		t.Errorf("step_count: слоёв %d, ждали 2", len(steps.Layers))
	}

	hr := find(t, metrics, "heart_rate")
	if hr.Aggregation.Style != catalog.Instant {
		t.Errorf("heart_rate: род %v", hr.Aggregation.Style)
	}

	sleep := find(t, metrics, "sleep_analysis")
	if sleep.Aggregation.Style != catalog.Unknown {
		t.Errorf("sleep_analysis: род %v, ждали unknown", sleep.Aggregation.Style)
	}
	if sleep.Aggregation.Hours != 0 || sleep.Aggregation.FirstHour != nil {
		t.Errorf("sleep_analysis: основание %+v — сравнивать было нечего", sleep.Aggregation.Basis)
	}
	if len(sleep.Layers) != 1 || sleep.Layers[0].Layer != "raw" {
		t.Errorf("sleep_analysis: слои %+v", sleep.Layers)
	}
}

// Нижний слой в сверке не участвует: у метрики есть `raw` и `hour`, но нет
// `minute` — рода быть не должно, сколько бы данных ни лежало в нижнем слое.
func TestКаталогНеИзмеряетПоНижнемуСлою(t *testing.T) {
	t.Parallel()

	st := openStore(t)
	var points []store.IncomingPoint
	for i := range 5 {
		h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
		points = append(points, incoming("active_energy", "hour", "kJ", h, 6))
		for m, v := range []float64{1, 2, 3} {
			points = append(points, incoming("active_energy", "raw",
				"kJ", h.Add(time.Duration(m)*time.Second), v))
		}
	}
	merge(t, st, points)

	metrics, err := metricsOf(context.Background(), service(t, st))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	m := find(t, metrics, "active_energy")
	if m.Aggregation.Style != catalog.Unknown {
		t.Errorf("род %v, ждали unknown", m.Aggregation.Style)
	}
	if m.Aggregation.Hours != 0 {
		t.Errorf("часов окна %d — нижний слой не имеет права попадать в сверку", m.Aggregation.Hours)
	}
}

func TestКаталогОграничиваетОкно(t *testing.T) {
	t.Parallel()

	st := openStore(t)
	fill(t, st, "step_count", catalog.Window+7, true)

	metrics, err := metricsOf(context.Background(), service(t, st))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	m := find(t, metrics, "step_count")
	if m.Aggregation.Hours != catalog.Window {
		t.Errorf("часов окна %d, ждали %d", m.Aggregation.Hours, catalog.Window)
	}
	// Окно берёт САМЫЕ СВЕЖИЕ часы: старейший из сравненных обязан быть позже
	// первого часа истории.
	first := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
	if m.Aggregation.FirstHour == nil || !m.Aggregation.FirstHour.After(first) {
		t.Errorf("начало окна %v — окно взяло не свежие часы", m.Aggregation.FirstHour)
	}
}

// Единицы, разошедшиеся между объектами, показываются множеством, а слой
// остаётся одним элементом: клиент, читающий слои словарём, иначе потерял бы
// половину диапазона молча.
func TestКаталогПоказываетРасхождениеЕдиниц(t *testing.T) {
	t.Parallel()

	st := openStore(t)
	base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
	merge(t, st, []store.IncomingPoint{
		incoming("walking_running_distance", "minute", "km", base, 1),
		incoming("walking_running_distance", "minute", "m", base.Add(2*time.Hour), 2),
	})

	metrics, err := metricsOf(context.Background(), service(t, st))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	m := find(t, metrics, "walking_running_distance")
	if len(m.Units) != 2 {
		t.Errorf("единицы %v, ждали оба значения", m.Units)
	}
	if len(m.Layers) != 1 {
		t.Fatalf("слоёв %d, ждали один элемент на слой: %+v", len(m.Layers), m.Layers)
	}
	if m.Layers[0].Points != 2 {
		t.Errorf("точек в слое %d, ждали 2 — диапазоны обязаны объединиться", m.Layers[0].Points)
	}
	if !m.Layers[0].From.Equal(base) || !m.Layers[0].To.Equal(base.Add(2*time.Hour)) {
		t.Errorf("границы слоя %v..%v", m.Layers[0].From, m.Layers[0].To)
	}
}

func TestКаталогПустойВитриныПуст(t *testing.T) {
	t.Parallel()

	metrics, err := metricsOf(context.Background(), service(t, openStore(t)))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	if len(metrics) != 0 {
		t.Errorf("метрик %d, ждали ноль", len(metrics))
	}
}

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

	st := openStore(t)
	svc := service(t, st)
	ctx := context.Background()

	fill(t, st, "step_count", 2, true)
	before, err := metricsOf(ctx, svc)
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	if find(t, before, "step_count").Aggregation.Style != catalog.Unknown {
		t.Fatal("двух часов не хватает на вердикт — ждали unknown")
	}

	fill(t, st, "step_count", 5, true)
	after, err := metricsOf(ctx, svc)
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	if got := find(t, after, "step_count").Aggregation.Style; got != catalog.Cumulative {
		t.Errorf("после доставки род %v, ждали cumulative", got)
	}
}

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

	st := openStore(t)
	fill(t, st, "blood_oxygen_saturation", 6, false) // прошлое: среднее → instant

	// Будущее: столько же часов «суммой», сколько влезает в окно целиком.
	var future []store.IncomingPoint
	base := store.Now().Add(72 * time.Hour).Truncate(time.Hour)
	for i := range catalog.Window {
		h := base.Add(time.Duration(i) * time.Hour)
		future = append(future, incoming("blood_oxygen_saturation", "hour", "count", h, 6))
		for m, v := range []float64{1, 2, 3} {
			future = append(future, incoming("blood_oxygen_saturation", "minute", "count",
				h.Add(time.Duration(m)*time.Minute), v))
		}
	}
	merge(t, st, future)

	handler := &logged{}
	metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler)))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	m := find(t, metrics, "blood_oxygen_saturation")
	if m.Aggregation.Style != catalog.Instant {
		t.Errorf("род %v: часы из будущего заняли окно и подменили измерение", m.Aggregation.Style)
	}
	if m.Aggregation.LastHour == nil || m.Aggregation.LastHour.After(store.Now().Add(time.Hour)) {
		t.Errorf("конец окна %v — окно ушло в будущее", m.Aggregation.LastHour)
	}
	if _, ok := handler.find("future data"); !ok {
		t.Error("данные из будущего есть, а предупреждения владельцу нет")
	}
}

// Единицы обеих сторон обязаны совпасть: минутный слой в count/min и часовой в
// count/hour дают «часовое = 60 · среднее = сумма» в полном часе, то есть
// уверенный ложный cumulative при нуле противоречащих часов.
func TestКаталогНеСверяетСлоиРазныхЕдиниц(t *testing.T) {
	t.Parallel()

	st := openStore(t)
	var points []store.IncomingPoint
	for i := range 6 {
		h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
		points = append(points, incoming("walking_speed", "hour", "count/hour", h, 6))
		for m, v := range []float64{1, 2, 3} {
			points = append(points, incoming("walking_speed", "minute", "count/min",
				h.Add(time.Duration(m)*time.Minute), v))
		}
	}
	merge(t, st, points)

	metrics, err := metricsOf(context.Background(), service(t, st))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	m := find(t, metrics, "walking_speed")
	if m.Aggregation.Style != catalog.Unknown {
		t.Errorf("род %v: единицы слоёв разошлись, сравнивать было нечего", m.Aggregation.Style)
	}
	if m.Aggregation.Compared != 0 {
		t.Errorf("пригодных часов %d, ждали 0", m.Aggregation.Compared)
	}
}

// Пустое имя метрики — законное значение колонки. Каталог отвечает на вопрос
// «что у тебя вообще есть», и терять на нём то, что лежит в витрине, нельзя.
func TestКаталогПоказываетМетрикуСПустымИменем(t *testing.T) {
	t.Parallel()

	st := openStore(t)
	base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
	merge(t, st, []store.IncomingPoint{
		incoming("", "minute", "count", base, 1),
		incoming("step_count", "minute", "count", base, 2),
	})

	metrics, err := metricsOf(context.Background(), service(t, st))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	if len(metrics) != 2 {
		t.Fatalf("метрик в каталоге %d, ждали 2: %+v", len(metrics), metrics)
	}
	if metrics[0].Metric != "" || metrics[0].Layers[0].Points != 1 {
		t.Errorf("метрика с пустым именем потерялась: %+v", metrics[0])
	}
}

func TestКаталогПишетПредупреждениеОПротиворечии(t *testing.T) {
	t.Parallel()

	st := openStore(t)
	long := strings.Repeat("метрика", 200)
	var points []store.IncomingPoint
	base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
	for i := range 6 {
		h := base.Add(time.Duration(i) * time.Hour)
		coarse := 6.0 // сумма
		if i >= 4 {
			coarse = 2.0 // среднее — противоречие
		}
		points = append(points, incoming(long, "hour", "count", h, coarse))
		for m, v := range []float64{1, 2, 3} {
			points = append(points, incoming(long, "minute", "count", h.Add(time.Duration(m)*time.Minute), v))
		}
	}
	merge(t, st, points)

	handler := &logged{}
	metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler)))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	if find(t, metrics, long).Aggregation.Style != catalog.Unknown {
		t.Error("противоречие обязано гасить род")
	}

	rec, ok := handler.find("aggregation style conflict")
	if !ok {
		t.Fatal("противоречие есть, а предупреждения владельцу нет")
	}
	if rec.Level != slog.LevelWarn {
		t.Errorf("уровень %v, ждали WARN: адресат записи — владелец", rec.Level)
	}
	if got := attr(rec, "metric"); len(got) > 128 {
		t.Errorf("имя метрики в логе не обрезано: %d байт", len(got))
	}
	if attr(rec, "conflicting") == "" {
		t.Error("в записи нет чисел основания — по ней нечего разбирать")
	}
}

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

	st := openStore(t)
	base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
	var points []store.IncomingPoint
	for i := range 40 {
		points = append(points, incoming("step_count", "minute",
			fmt.Sprintf("unit-%02d", i), base.Add(time.Duration(i)*time.Hour), 1))
	}
	merge(t, st, points)

	metrics, err := metricsOf(context.Background(), service(t, st))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	m := find(t, metrics, "step_count")
	if len(m.Units) == 0 || len(m.Units) > 16 {
		t.Errorf("единиц в ответе %d — потолок не работает", len(m.Units))
	}
	if len(m.Layers) != 1 {
		t.Errorf("слоёв %d, ждали один элемент на слой", len(m.Layers))
	}
}

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

	st := openStore(t)
	if err := st.Close(); err != nil {
		t.Fatalf("закрытие базы: %v", err)
	}

	handler := &logged{}
	if _, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler))); err == nil {
		t.Fatal("каталог на закрытой базе собрался")
	}
	rec, ok := handler.find("catalog failed")
	if !ok {
		t.Fatal("отказ не записан на доменной границе")
	}
	if rec.Level != slog.LevelError {
		t.Errorf("уровень %v, ждали ERROR: сбой хранилища адресован владельцу", rec.Level)
	}
}

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

	st := openStore(t)
	fill(t, st, "step_count", 3, true)

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

	handler := &logged{}
	if _, err := metricsOf(ctx, catalog.New(st, slog.New(handler))); err == nil {
		t.Fatal("каталог собрался на отменённом контексте")
	}
	if _, ok := handler.find("catalog failed"); ok {
		t.Error("отмена снаружи записана как сбой сервиса")
	}
	if _, ok := handler.find("catalog interrupted"); !ok {
		t.Error("отмена не отмечена вовсе — исход операции обязан быть виден")
	}
}

// metricsOf — список метрик каталога без версии витрины: версию проверяют
// отдельные тесты, остальным нужен только состав ответа.
func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) {
	snap, err := s.Metrics(ctx)
	return snap.Metrics, err
}

// Каталог подписывает свой ответ версией витрины: без неё транспорту нечего
// поставить в `ETag`, и условный запрос не работает вовсе.
func TestКаталогОтдаётсяСВерсиейВитрины(t *testing.T) {
	t.Parallel()

	st := openStore(t)
	snap, err := service(t, st).Metrics(context.Background())
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	if snap.Version == "" {
		t.Error("каталог собран на стоящей витрине и остался без версии")
	}
}

// Версия ответа каталога — это версия витрины ПЛЮС горизонт измерения.
//
// Утверждение прямое, потому что склейка теперь общая: её же зовёт маршрут
// точек. Сломай её — и оба маршрута начнут подтверждать неизменность ответа,
// чей род перевернулся ходом часов, а не коммитом.
func TestВерсияКаталогаНесётГоризонт(t *testing.T) {
	st := openStore(t)
	ctx := context.Background()

	got, err := service(t, st).Version(ctx)
	if err != nil {
		t.Fatalf("Version: %v", err)
	}

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