package store_test

import (
	"context"
	"encoding/json"
	"testing"
	"time"

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

func window(hours int, horizon time.Time) store.CatalogWindow {
	return store.CatalogWindow{
		Fine: "minute", Coarse: "hour",
		Hours: hours, Horizon: horizon,
		CoarsePoints: 1, MinFinePoints: 2,
	}
}

// hourly кладёт метрику в оба слоя за `hours` часов начиная с `base`.
func hourly(t *testing.T, st *store.Store, metric, units string, base time.Time, hours int) {
	t.Helper()

	var in []store.IncomingPoint
	for i := range hours {
		h := base.Add(time.Duration(i) * time.Hour)
		in = append(in, store.IncomingPoint{
			Metric: metric, Layer: "hour", Units: units,
			Point: store.Point{Start: h, End: h, Raw: json.RawMessage(`{"qty":6}`)},
		})
		for m, v := range []string{`{"qty":1}`, `{"qty":2}`, `{"qty":3}`} {
			at := h.Add(time.Duration(m) * time.Minute)
			in = append(in, store.IncomingPoint{
				Metric: metric, Layer: "minute", Units: units,
				Point: store.Point{Start: at, End: at, Raw: json.RawMessage(v)},
			})
		}
	}
	if _, err := st.Merge(context.Background(), store.Incoming{Points: in},
		store.DeliveryRef{ID: "delivery"}); err != nil {
		t.Fatalf("слияние: %v", err)
	}
}

func TestReadCatalogОтдаётРазрезыИОкно(t *testing.T) {
	t.Parallel()

	st := open(t)
	base := ts(t, "2026-06-01T00:00:00Z")
	hourly(t, st, "step_count", "count", base, 3)

	snap, err := st.ReadCatalog(t.Context(), window(48, base.Add(24*time.Hour)))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	if len(snap.Layers) != 2 {
		t.Fatalf("разрезов %d, ждали 2: %+v", len(snap.Layers), snap.Layers)
	}
	pairs := snap.Pairs["step_count"]
	if len(pairs) != 3 {
		t.Fatalf("общих часов %d, ждали 3", len(pairs))
	}
	// От свежих к старым — на этот порядок опирается и окно, и границы основания.
	if !pairs[0].Hour.After(pairs[len(pairs)-1].Hour) {
		t.Errorf("часы пришли не от свежих к старым: %v", pairs[0].Hour)
	}
	if len(pairs[0].Coarse) != 1 || len(pairs[0].Fine) != 3 {
		t.Errorf("содержимое пары не прочитано: %+v", pairs[0])
	}
	if pairs[0].FineUnits != "count" || pairs[0].CoarseUnits != "count" {
		t.Errorf("единицы сторон не прочитаны: %+v", pairs[0])
	}
}

// Час объекта берётся из метки в теле доставки. Без горизонта одна доставка с
// метками в будущем занимает окно целиком и подменяет измеренный род.
func TestReadCatalogОтсекаетБудущиеЧасы(t *testing.T) {
	t.Parallel()

	st := open(t)
	base := ts(t, "2026-06-01T00:00:00Z")
	hourly(t, st, "step_count", "count", base, 2)
	hourly(t, st, "step_count", "count", ts(t, "2099-01-01T00:00:00Z"), 5)

	horizon := base.Add(24 * time.Hour)
	snap, err := st.ReadCatalog(t.Context(), window(48, horizon))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	pairs := snap.Pairs["step_count"]
	if len(pairs) != 2 {
		t.Fatalf("часов в окне %d, ждали 2: будущее не отсечено", len(pairs))
	}
	for _, p := range pairs {
		if p.Hour.After(horizon) {
			t.Errorf("час %v позже горизонта %v", p.Hour, horizon)
		}
	}
}

// Окно ограничено сверху и берёт самые свежие часы.
func TestReadCatalogОграничиваетОкно(t *testing.T) {
	t.Parallel()

	st := open(t)
	base := ts(t, "2026-06-01T00:00:00Z")
	hourly(t, st, "step_count", "count", base, 10)

	snap, err := st.ReadCatalog(t.Context(), window(4, base.Add(48*time.Hour)))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	pairs := snap.Pairs["step_count"]
	if len(pairs) != 4 {
		t.Fatalf("часов %d, ждали 4", len(pairs))
	}
	if !pairs[0].Hour.Equal(base.Add(9 * time.Hour)) {
		t.Errorf("самый свежий час %v, ждали %v", pairs[0].Hour, base.Add(9*time.Hour))
	}
}

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

	st := open(t)
	base := ts(t, "2026-06-01T00:00:00Z")
	in := []store.IncomingPoint{
		// Часовой объект с двумя точками: час непригоден.
		{Metric: "apple_stand_hour", Layer: "hour", Units: "count",
			Point: store.Point{Start: base, End: base, Raw: json.RawMessage(`{"qty":1}`)}},
		{Metric: "apple_stand_hour", Layer: "hour", Units: "count",
			Point: store.Point{Start: base, End: base.Add(time.Hour), Raw: json.RawMessage(`{"qty":1}`)}},
	}
	for m, v := range []string{`{"qty":1}`, `{"qty":2}`} {
		at := base.Add(time.Duration(m) * time.Minute)
		in = append(in, store.IncomingPoint{Metric: "apple_stand_hour", Layer: "minute", Units: "count",
			Point: store.Point{Start: at, End: at, Raw: json.RawMessage(v)}})
	}
	if _, err := st.Merge(t.Context(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil {
		t.Fatalf("слияние: %v", err)
	}

	snap, err := st.ReadCatalog(t.Context(), window(48, base.Add(24*time.Hour)))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	pairs := snap.Pairs["apple_stand_hour"]
	if len(pairs) != 1 {
		t.Fatalf("часов %d, ждали 1", len(pairs))
	}
	if pairs[0].CoarsePoints != 2 || pairs[0].FinePoints != 2 {
		t.Errorf("учётные колонки не прочитаны: %+v", pairs[0])
	}
	if pairs[0].Coarse != nil || pairs[0].Fine != nil {
		t.Errorf("содержимое непригодного часа разжато напрасно: %+v", pairs[0])
	}
}

func TestReadCatalogОтвергаетНеверноеОкно(t *testing.T) {
	t.Parallel()

	st := open(t)
	cases := map[string]store.CatalogWindow{
		"нулевое окно":      {Fine: "minute", Coarse: "hour", Hours: 0},
		"слои совпадают":    {Fine: "minute", Coarse: "minute", Hours: 48},
		"окно отрицательно": {Fine: "minute", Coarse: "hour", Hours: -1},
	}
	for name, w := range cases {
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			if _, err := st.ReadCatalog(context.Background(), w); err == nil {
				t.Error("окно вне контракта принято без ошибки")
			}
		})
	}
}
