package store_test

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

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

func open(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 ts(t *testing.T, s string) time.Time {
	t.Helper()

	v, err := time.Parse(time.RFC3339, s)
	if err != nil {
		t.Fatalf("метка %q: %v", s, err)
	}
	return v.UTC()
}

func point(t *testing.T, metric, layer, start, end, raw string) store.IncomingPoint {
	t.Helper()

	return store.IncomingPoint{
		Metric: metric,
		Layer:  layer,
		Units:  "count",
		Point: store.Point{
			Start:         ts(t, start),
			End:           ts(t, end),
			OffsetSeconds: 3 * 3600,
			Raw:           json.RawMessage(raw),
		},
	}
}

func TestMergeКладётЧасОднимОбъектом(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	in := []store.IncomingPoint{
		point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
		point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
		point(t, "step_count", "minute", "2025-06-05T11:00:00Z", "2025-06-05T11:00:00Z", `{"qty":3}`),
	}

	stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "delivery-1"})
	if err != nil {
		t.Fatalf("слияние: %v", err)
	}
	if stats.Buckets != 2 {
		t.Errorf("объектов %d, ожидалось 2 (два разных часа)", stats.Buckets)
	}

	b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение объекта: %v", err)
	}
	if len(b.Points) != 2 {
		t.Fatalf("точек в объекте %d, ожидалось 2", len(b.Points))
	}
	if b.Units != "count" {
		t.Errorf("единицы %q потеряны: внутри точки их нет", b.Units)
	}
	if b.Delivery != "delivery-1" {
		t.Errorf("провенанс %q, ожидался delivery-1", b.Delivery)
	}
	if !b.FirstTS.Equal(ts(t, "2025-06-05T10:00:00Z")) || !b.LastTS.Equal(ts(t, "2025-06-05T10:30:00Z")) {
		t.Errorf("границы содержимого %v..%v", b.FirstTS, b.LastTS)
	}
}

// Дозапись в существующий час: объект перечитывается, точки сливаются, ранее
// сохранённые остаются. Точки из объекта не удаляются никогда.
func TestMergeДозаписьНеТеряетСохранённое(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	first := []store.IncomingPoint{
		point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
	}
	second := []store.IncomingPoint{
		point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
	}

	if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	if _, err := st.Merge(ctx, store.Incoming{Points: second}, store.DeliveryRef{ID: "d2"}); err != nil {
		t.Fatalf("второе слияние: %v", err)
	}

	b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if len(b.Points) != 2 {
		t.Fatalf("точек %d, ожидалось 2: дозапись затёрла сохранённое", len(b.Points))
	}
	if b.Delivery != "d1" {
		t.Errorf("провенанс %q: объект создала первая доставка", b.Delivery)
	}
}

// Хеш — детектор изменений: повтор того же часа не пишет в базу. Именно это
// делает широкие проходы синхронизации дешёвыми.
func TestMergeПовторНеПишет(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	in := []store.IncomingPoint{
		point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1,"source":"Device A"}`),
		point(t, "step_count", "minute", "2025-06-05T10:01:00Z", "2025-06-05T10:01:00Z", `{"qty":2,"source":"Device A"}`),
	}

	if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	before, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}

	stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("повторное слияние: %v", err)
	}
	if stats.Unchanged != 1 {
		t.Errorf("объектов без изменений %d, ожидался 1", stats.Unchanged)
	}
	// Повтор — не столкновение: точка сравнивается сама с собой, и ни один
	// счётчик правила слияния расти не должен.
	if stats.Overwrites != 0 || stats.Incomparable != 0 {
		t.Errorf("повтор посчитан столкновением: перезаписей %d, несравнимых %d",
			stats.Overwrites, stats.Incomparable)
	}

	after, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение после повтора: %v", err)
	}
	if before.Hash != after.Hash {
		t.Error("хеш изменился при повторе того же содержимого")
	}
	if len(after.Points) != len(before.Points) {
		t.Errorf("точек стало %d вместо %d", len(after.Points), len(before.Points))
	}
}

// Порядок точек внутри доставки нестабилен (находка 2), и идентичность не
// имеет права от него зависеть.
func TestMergeИдемпотентенКПорядкуТочек(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	in := []store.IncomingPoint{
		point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
		point(t, "step_count", "minute", "2025-06-05T10:01:00Z", "2025-06-05T10:01:00Z", `{"qty":2}`),
		point(t, "step_count", "minute", "2025-06-05T10:02:00Z", "2025-06-05T10:02:00Z", `{"qty":3}`),
	}
	reversed := []store.IncomingPoint{in[2], in[1], in[0]}

	if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	first, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}

	stats, err := st.Merge(ctx, store.Incoming{Points: reversed}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("слияние в обратном порядке: %v", err)
	}
	if stats.Unchanged != 1 {
		t.Error("перестановка точек засчиталась изменением")
	}

	second, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if first.Hash != second.Hash {
		t.Error("хеш зависит от порядка точек в доставке")
	}
}

// Ключ по метке схлопнул бы эти три записи в одну. Живьём такое встречается в
// 22 доставках из 94 (находка 47), причём внутри одной доставки — там тай-брейк
// по времени приёма неприменим в принципе.
func TestMergeЗаписиСОднойМеткойНеСхлопываются(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	in := []store.IncomingPoint{
		point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-05T19:16:00Z", `{"qty":0.2,"value":"Во сне"}`),
		point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-05T23:21:00Z", `{"qty":4.28,"value":"В кровати"}`),
		point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78,"value":"В кровати"}`),
	}

	if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("слияние: %v", err)
	}

	b, err := st.Bucket(ctx, "sleep_analysis", "minute", ts(t, "2025-06-05T19:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if len(b.Points) != 3 {
		t.Fatalf("точек %d, ожидалось 3: ключ схлопнул записи с одной меткой", len(b.Points))
	}

	// Повтор той же тройки не задваивает: координата включает интервал, и он
	// совпадает.
	stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("повтор: %v", err)
	}
	if stats.Unchanged != 1 {
		t.Errorf("повтор записей засчитан изменением: %+v", stats)
	}
}

// Эпизод, пересекающий границу часа, ложится в объект по НАЧАЛУ: любой другой
// выбор сделал бы принадлежность объекту зависящей от длительности.
func TestMergeЧасПоНачалуИнтервала(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	in := []store.IncomingPoint{
		point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78}`),
	}
	if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("слияние: %v", err)
	}

	if _, err := st.Bucket(ctx, "sleep_analysis", "minute", ts(t, "2025-06-05T19:00:00Z")); err != nil {
		t.Fatalf("объект часа начала не найден: %v", err)
	}
	if _, err := st.Bucket(ctx, "sleep_analysis", "minute", ts(t, "2025-06-06T04:00:00Z")); !errors.Is(err, store.ErrNotFound) {
		t.Error("эпизод попал ещё и в объект часа окончания")
	}
}

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

	st := open(t)
	ctx := context.Background()

	rich := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"Avg":60,"Min":55,"Max":70,"context":"Отдых"}`)
	poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"Avg":60,"Min":55,"Max":70}`)

	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("второе слияние: %v", err)
	}

	b, err := st.Bucket(ctx, "heart_rate", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if len(b.Points) != 1 {
		t.Fatalf("точек %d, ожидалась 1", len(b.Points))
	}
	if !strings.Contains(string(b.Points[0].Raw), "context") {
		t.Errorf("бедная точка стёрла поле context: %s", b.Points[0].Raw)
	}
	if stats.Overwrites != 1 {
		t.Errorf("столкновений %d, ожидалось 1: различие обязано оставить след", stats.Overwrites)
	}
}

// Полнота — множество ключей, а не их число. Счётчик значащих полей давал
// сохранённой точке 5 против 2 и стирал настоящее измерение безвозвратно:
// восстановить его можно было бы только из сырого архива, пока он жив.
func TestMergeПоляБезСодержанияНеСтираютИзмерение(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	hollow := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"qty":0,"a":0,"b":0,"c":{},"d":[]}`)
	measured := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`)

	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{hollow}}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{measured}}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("второе слияние: %v", err)
	}

	b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if got := string(b.Points[0].Raw); got != string(measured.Raw) {
		t.Errorf("измерение стёрто точкой без содержания: %s", got)
	}
	if stats.Incomparable != 0 {
		t.Errorf("несравнимых %d: наборы сравнимы, содержательных ключей у первой нет",
			stats.Incomparable)
	}
}

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

	st := open(t)
	ctx := context.Background()

	// Значение общего содержательного ключа СОВПАДАЕТ — таково условие второго
	// разряда в спеке: он сравнивает все ключи только там, где содержание
	// сошлось. Прежняя редакция теста давала `qty` разные, второй разряд при
	// этом не запускался вовсе, и точка с `Min`/`Max` выигрывала по случайности
	// байтового порядка (`M` сортируется раньше `d`). Зелёный тест проверял не
	// то требование, которое называл.
	wide := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"date":"d","qty":10,"Min":0,"Max":0}`)
	narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"date":"d","qty":10}`)

	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{narrow}}, store.DeliveryRef{ID: "d2"}); err != nil {
		t.Fatalf("второе слияние: %v", err)
	}

	b, err := st.Bucket(ctx, "heart_rate", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if got := string(b.Points[0].Raw); got != string(wide.Raw) {
		t.Errorf("ключи Min и Max потеряны: %s", got)
	}
}

// Обратная сторона того же разряда, и она названа ценой изменения: когда
// значения общих содержательных ключей РАЗОШЛИСЬ, полнота ответа не даёт по
// построению («надмножество имён о полноте не говорит ничего»), и пришедшая
// точка побеждает вместе с потерей пустых ключей сохранённой. Прежде исход
// здесь решался байтовым порядком, то есть был случайным.
func TestMergeРазошедшиесяЗначенияНеУдерживаютсяПустымиКлючами(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	wide := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"date":"d","qty":10,"Min":0,"Max":0}`)
	narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"date":"d","qty":12}`)

	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{narrow}}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("второе слияние: %v", err)
	}

	b, err := st.Bucket(ctx, "heart_rate", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if got := string(b.Points[0].Raw); got != string(narrow.Raw) {
		t.Errorf("в витрине %s, ожидалась пришедшая точка %s", got, narrow.Raw)
	}
	if stats.Overwrites != 1 {
		t.Errorf("перезаписей %d, ожидалась 1", stats.Overwrites)
	}
	if stats.PointsHeld != 0 {
		t.Errorf("удержаний %d: пришедшая победила, удерживать было нечего", stats.PointsHeld)
	}
}

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

	st := open(t)
	ctx := context.Background()

	rich := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"Avg":72,"Min":60,"Max":90,"context":"rest"}`)
	poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"Avg":72,"Min":60,"Max":90}`)

	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("второе слияние: %v", err)
	}

	b, err := st.Bucket(ctx, "heart_rate", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if got := string(b.Points[0].Raw); got != string(rich.Raw) {
		t.Errorf("в витрине %s: пришедшая бедная точка стёрла содержательный ключ", got)
	}
	if stats.PointsHeld != 1 {
		t.Errorf("удержаний %d, ожидалось 1", stats.PointsHeld)
	}
	if len(stats.PointsHeldAt) != 1 {
		t.Fatalf("координат удержания %d, ожидалась 1", len(stats.PointsHeldAt))
	}
	if stats.PointsHeldAt[0].Metric != "heart_rate" || stats.PointsHeldAt[0].Layer != "minute" {
		t.Errorf("координаты удержания %+v: не те", stats.PointsHeldAt[0])
	}
}

// Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
// счётчик и координаты объекта, по которым событие можно будет разобрать.
func TestMergeНесравнимыеНаборыСчитаются(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	a := point(t, "blood_glucose", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"qty":5.1}`)
	b := point(t, "blood_glucose", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"mealTime":"До еды"}`)

	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{a}}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{b}}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("второе слияние: %v", err)
	}

	if stats.Incomparable != 1 {
		t.Fatalf("несравнимых %d, ожидался 1", stats.Incomparable)
	}
	// Несравнимость — частный случай столкновения: иначе сумма перезаписей за
	// период перестала бы быть сравнимой с прежней.
	if stats.Overwrites != 1 {
		t.Errorf("перезаписей %d, ожидалась 1", stats.Overwrites)
	}
	if len(stats.IncomparableAt) != 1 {
		t.Fatalf("координат %d, ожидалась 1", len(stats.IncomparableAt))
	}
	if got := stats.IncomparableAt[0]; got.Metric != "blood_glucose" || got.Layer != "minute" {
		t.Errorf("координаты объекта не те: %s/%s", got.Metric, got.Layer)
	}

	got, err := st.Bucket(ctx, "blood_glucose", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if len(got.Points) != 1 {
		t.Errorf("точек %d, ожидалась 1: правило обязано выбрать одну", len(got.Points))
	}
}

// Список координат упирается в потолок, счётчик — нет: обрезанный список
// остаётся зацепкой для разбора, а масштаб события считает счётчик.
func TestMergeСчётчикРастётПослеПотолкаКоординат(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	const hours = 8
	var first, second []store.IncomingPoint
	for h := range hours {
		at := fmt.Sprintf("2025-06-05T%02d:00:00Z", h)
		first = append(first, point(t, "blood_glucose", "minute", at, at, `{"qty":5.1}`))
		second = append(second, point(t, "blood_glucose", "minute", at, at, `{"mealTime":"До еды"}`))
	}

	if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	stats, err := st.Merge(ctx, store.Incoming{Points: second}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("второе слияние: %v", err)
	}

	if stats.Incomparable != hours {
		t.Errorf("несравнимых %d, ожидалось %d: счётчик остановился вместе со списком",
			stats.Incomparable, hours)
	}
	if len(stats.IncomparableAt) >= hours {
		t.Errorf("координат %d — список не обрезан потолком", len(stats.IncomparableAt))
	}
}

// source нестабилен: то же измерение приезжает то с одним именем устройства,
// то с другим. Он не входит в ключ и не считается полнотой.
func TestMergeСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	a := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"qty":1,"source":"Apple Watch Ultra 3|iPad (Anton)"}`)
	b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"qty":1,"source":"Apple Watch Ultra 3"}`)

	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{a}}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{b}}, store.DeliveryRef{ID: "d2"}); err != nil {
		t.Fatalf("второе слияние: %v", err)
	}

	got, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if len(got.Points) != 1 {
		t.Fatalf("точек %d, ожидалась 1: смена source задвоила точку", len(got.Points))
	}
}

// Внутри ОДНОЙ доставки провенанс общий, различать точки нечем, а порядок
// элементов в JSON-массиве от HAE нестабилен — поэтому здесь исход обязан
// остаться функцией множества и решаться порядком канонических форм.
func TestMergeРавнаяПолнотаВнутриДоставкиНеЗависитОтПорядка(t *testing.T) {
	t.Parallel()

	ctx := context.Background()

	a := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
	b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)

	winner := func(batch []store.IncomingPoint) string {
		st := open(t)
		if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
			t.Fatalf("слияние: %v", err)
		}
		got, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
		if err != nil {
			t.Fatalf("чтение: %v", err)
		}
		return string(got.Points[0].Raw)
	}

	forward := winner([]store.IncomingPoint{a, b})
	backward := winner([]store.IncomingPoint{b, a})
	if forward != backward {
		t.Errorf("исход внутри доставки зависит от порядка элементов: %s против %s", forward, backward)
	}
	if forward != string(a.Raw) {
		t.Errorf("внутри доставки победила %s, ожидался минимум канонической формы %s", forward, a.Raw)
	}
}

// А МЕЖДУ доставками побеждает пришедшая, и это осознанная потеря
// коммутативности: байтовый порядок системно хранил меньшее значение и
// стоил `step_count` его рода. Витрина остаётся свёрткой журнала потому, что
// порядок свёртки приведён к порядку журнала, а не потому, что правило
// безразлично к порядку.
func TestMergeРавнаяПолнотаМеждуДоставкамиБерётПришедшую(t *testing.T) {
	t.Parallel()

	ctx := context.Background()

	a := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
	b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)

	last := func(order []store.IncomingPoint) string {
		st := open(t)
		for i, p := range order {
			ref := store.DeliveryRef{ID: fmt.Sprintf("d%d", i)}
			if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, ref); err != nil {
				t.Fatalf("слияние: %v", err)
			}
		}
		got, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
		if err != nil {
			t.Fatalf("чтение: %v", err)
		}
		return string(got.Points[0].Raw)
	}

	if got := last([]store.IncomingPoint{a, b}); got != string(b.Raw) {
		t.Errorf("после доставок a,b в витрине %s, ожидалась пришедшая последней %s", got, b.Raw)
	}
	// Обратный порядок даёт обратный исход — ровно то свойство, ради которого
	// заведён барьер журнального порядка в воркере.
	if got := last([]store.IncomingPoint{b, a}); got != string(a.Raw) {
		t.Errorf("после доставок b,a в витрине %s, ожидалась пришедшая последней %s", got, a.Raw)
	}
}

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

	st := open(t)
	ctx := context.Background()

	first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"date":"d","qty":10}`)
	// Тот же смысл, другие байты: порядок ключей у HAE нестабилен.
	again := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
		`{"qty":10,"date":"d"}`)

	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{again}}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("второе слияние: %v", err)
	}
	if stats.Overwrites != 0 {
		t.Errorf("перезаписей %d: дребезг порядка ключей посчитан столкновением", stats.Overwrites)
	}
	if stats.Unchanged != 1 {
		t.Errorf("неизменившихся объектов %d, ожидался 1: объект переписан зря", stats.Unchanged)
	}

	b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if got := string(b.Points[0].Raw); got != string(first.Raw) {
		t.Errorf("в витрине %s, ожидались сохранённые байты %s", got, first.Raw)
	}
}

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

	ctx := context.Background()

	stored := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
	same := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
	other := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)

	st := open(t)
	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{stored}}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	// Вторая доставка несёт обе формы: совпавшую с сохранённой и другую.
	// Победить обязана меньшая каноническая форма, то есть совпавшая, — а не
	// вторая только потому, что у первой не поднялся флаг.
	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{same, other}}, store.DeliveryRef{ID: "d2"}); err != nil {
		t.Fatalf("второе слияние: %v", err)
	}

	b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if got := string(b.Points[0].Raw); got != string(stored.Raw) {
		t.Errorf("в витрине %s, ожидался минимум канонической формы доставки %s", got, stored.Raw)
	}
}

// Содержимое точки хранится исходными байтами: пересборка повторной
// сериализацией теряет литерал, и потеря не видна тестам, сравнивающим
// разобранное с разобранным.
func TestMergeХранитТочкуДословно(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	raw := "{\"exact\":1.0,\"huge\":9007199254740993,\"tail\":0.095231829624713534,\"broken\":\"a\\ufffdb\"}"
	in := []store.IncomingPoint{
		point(t, "unknown", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw),
	}
	if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("слияние: %v", err)
	}

	b, err := st.Bucket(ctx, "unknown", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if got := string(b.Points[0].Raw); got != raw {
		t.Errorf("точка изменилась при хранении:\n  было  %s\n  стало %s", raw, got)
	}
}

// Тело доставки доходит до 42 МиБ, приходят они непрерывно и внахлёст.
// Конкурентное слияние того же часа не имеет права терять точки: между
// чтением и записью может вклиниться другая доставка.
func TestMergeКонкурентноеСлияниеНеТеряетТочки(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	const writers = 8
	const perWriter = 25

	var wg sync.WaitGroup
	errs := make(chan error, writers)

	for w := range writers {
		wg.Go(func() {
			for i := range perWriter {
				minute := w*perWriter + i
				at := ts(t, "2025-06-05T10:00:00Z").Add(time.Duration(minute) * time.Second)
				p := store.IncomingPoint{
					Metric: "step_count",
					Layer:  "raw",
					Units:  "count",
					Point: store.Point{
						Start: at,
						End:   at,
						Raw:   json.RawMessage(`{"qty":` + itoa(minute) + `}`),
					},
				}
				if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
					errs <- err
					return
				}
			}
		})
	}

	wg.Wait()
	close(errs)
	for err := range errs {
		t.Fatalf("конкурентное слияние: %v", err)
	}

	b, err := st.Bucket(ctx, "step_count", "raw", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if len(b.Points) != writers*perWriter {
		t.Errorf("точек %d, ожидалось %d: конкурентная запись потеряла точки",
			len(b.Points), writers*perWriter)
	}
}

func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	var b []byte
	for n > 0 {
		b = append([]byte{byte('0' + n%10)}, b...)
		n /= 10
	}
	return string(b)
}

// Изменение запечатанного часа — сигнал, а не отказ: данные пишутся всё равно,
// но факт обязан дойти до владельца сервиса. Без счётчика допущение «глубже
// такого-то порога досчёта не бывает» не получило бы ни одного наблюдения.
func TestMergeИзменениеЗапечатанногоЧаса(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()
	hour := ts(t, "2025-06-05T10:00:00Z")

	first := []store.IncomingPoint{
		point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
	}
	if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	if err := st.MarkSealed(ctx, "step_count", "minute", hour, true); err != nil {
		t.Fatalf("пометка sealed: %v", err)
	}

	late := []store.IncomingPoint{
		point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
	}
	stats, err := st.Merge(ctx, store.Incoming{Points: late}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("досчёт запечатанного часа: %v", err)
	}
	if stats.SealedHits != 1 {
		t.Errorf("изменений запечатанного часа %d, ожидалось 1", stats.SealedHits)
	}

	b, err := st.Bucket(ctx, "step_count", "minute", hour)
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if len(b.Points) != 2 {
		t.Errorf("точек %d, ожидалось 2: данные обязаны писаться несмотря на sealed", len(b.Points))
	}
	if !b.Sealed {
		t.Error("признак sealed сброшен дозаписью")
	}
}

// Отмена посреди слияния не имеет права оставить половину: объект либо
// прежний, либо полный.
func TestMergeОтменаНеОставляетПоловины(t *testing.T) {
	t.Parallel()

	st := open(t)

	base := []store.IncomingPoint{
		point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
	}
	if _, err := st.Merge(context.Background(), store.Incoming{Points: base}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}

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

	// Сущности идут в ТОЙ ЖЕ транзакции и пишутся ПОСЛЕ объектов, то есть на
	// той половине, где отмена вероятнее. Вынесение их во вторую транзакцию —
	// напрашивающаяся правка при жалобе на длину транзакции с маршрутом, и она
	// прошла бы зелёной, сломав «состояние пересобираемо»: доставка получила бы
	// failed при записанной тренировке.
	more := store.Incoming{
		Points: []store.IncomingPoint{
			point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
		},
		Workouts: []store.IncomingEntity{workout(t, "w-отменённая", `{"id":"w-отменённая","qty":1}`)},
	}
	if _, err := st.Merge(ctx, more, store.DeliveryRef{ID: "d2"}); err == nil {
		t.Fatal("слияние на отменённом контексте прошло успешно")
	}
	if _, err := st.Workout(context.Background(), "w-отменённая"); !errors.Is(err, store.ErrNotFound) {
		t.Errorf("тренировка отменённой доставки осталась в витрине: %v", err)
	}

	b, err := st.Bucket(context.Background(), "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if len(b.Points) != 1 {
		t.Errorf("точек %d, ожидалась 1: отмена оставила половинчатое состояние", len(b.Points))
	}
}

// Отношение победы обязано быть функцией МНОЖЕСТВА точек, а не порядка их
// поступления. Попарная свёртка этого не давала: полнота — частичный порядок,
// тай-брейк — тотальный, и вместе они образовывали цикл (A ⊃ B по ключам,
// B бьёт C тай-брейком, C бьёт A тай-брейком). Из-за цикла одна и та же
// доставка, свёрнутая дважды, давала два состояния витрины поочерёдно —
// хранилище переставало быть свёрткой журнала.
//
// Тройка ниже — ровно такая: её нашёл враждебный проход ревью.
const (
	cyclеB = `{"a":1,"b":1}`
	cyclеC = `{"a":2,"d":1}`
	cyclеA = `{"a":3,"b":1,"c":1}`
)

func cycleTriple(t *testing.T) []store.IncomingPoint {
	t.Helper()

	return []store.IncomingPoint{
		point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", cyclеB),
		point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", cyclеC),
		point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", cyclеA),
	}
}

func TestMergeПовторнаяСвёрткаНеМеняетСостояние(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()
	in := cycleTriple(t)

	state := func() (string, string) {
		t.Helper()

		if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
			t.Fatalf("слияние: %v", err)
		}
		b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
		if err != nil {
			t.Fatalf("чтение: %v", err)
		}
		fp, err := st.Fingerprint(ctx)
		if err != nil {
			t.Fatalf("отпечаток: %v", err)
		}
		return string(b.Points[0].Raw), fp
	}

	wantRaw, wantFP := state()
	for pass := 2; pass <= 4; pass++ {
		gotRaw, gotFP := state()
		if gotRaw != wantRaw || gotFP != wantFP {
			t.Fatalf("свёртка %d той же доставки изменила витрину:\n  было %s / %s\n  стало %s / %s",
				pass, wantRaw, wantFP[:16], gotRaw, gotFP[:16])
		}
	}
}

func TestMergeИсходНеЗависитОтПерестановки(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	in := cycleTriple(t)

	// Все шесть перестановок тройки ВНУТРИ одной доставки. Разбиение на разные
	// доставки сюда больше не входит намеренно: между доставками правило
	// перестало быть коммутативным — при равной полноте побеждает пришедшая, —
	// и требовать здесь независимости от порядка значило бы требовать отката
	// починки. Что осталось верным между доставками, проверяют
	// `TestMergeРавнаяПолнотаМеждуДоставкамиБерётПришедшую` (исход = последняя
	// доставка) и прогон сходимости живого пути с пересборкой в
	// `internal/replay`.
	perms := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}

	winner := func(order []int) string {
		st := open(t)
		batch := make([]store.IncomingPoint, 0, len(order))
		for _, i := range order {
			batch = append(batch, in[i])
		}
		if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
			t.Fatalf("слияние: %v", err)
		}
		b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
		if err != nil {
			t.Fatalf("чтение: %v", err)
		}
		return string(b.Points[0].Raw)
	}

	want := winner(perms[0])
	for _, p := range perms {
		if got := winner(p); got != want {
			t.Errorf("перестановка %v дала %s, ожидалось %s", p, got, want)
		}
	}
}

// Та же тройка, разложенная по РАЗНЫМ доставкам: исход теперь зависит от
// порядка, и это не дефект, а объявленная цена. Проверяется другое и не менее
// важное — что зависимость ДЕТЕРМИНИРОВАНА: один и тот же порядок журнала
// обязан давать один и тот же исход, иначе витрина перестала бы быть свёрткой
// журнала вовсе, а не только функцией его порядка.
func TestMergeПорядокДоставокДаётВоспроизводимыйИсход(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	in := cycleTriple(t)
	perms := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}

	winner := func(order []int) string {
		st := open(t)
		for n, i := range order {
			ref := store.DeliveryRef{ID: fmt.Sprintf("d%d", n)}
			if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in[i]}}, ref); err != nil {
				t.Fatalf("слияние: %v", err)
			}
		}
		b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
		if err != nil {
			t.Fatalf("чтение: %v", err)
		}
		return string(b.Points[0].Raw)
	}

	for _, p := range perms {
		first := winner(p)
		for pass := 2; pass <= 3; pass++ {
			if got := winner(p); got != first {
				t.Errorf("порядок %v дал %s, а прежде %s: исход невоспроизводим", p, got, first)
			}
		}
	}
}

// Точка, ни одно значение которой не несёт измерения, не должна вытеснять
// настоящее измерение. Раньше вытесняла: множества содержательных ключей
// равны, и решал второй разряд — по ключам, а не по содержанию.
func TestMergeПадингНеВытесняетИзмерение(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	const real = `{"date":"d","qty":72.5}`

	cases := []struct {
		name string
		junk string
	}{
		{"падинг из null", `{"date":"d","qty":0.001,"p1":null,"p2":null,"p3":null}`},
		{"падинг из false", `{"date":"d","qty":false,"Min":false,"Max":false}`},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			t.Parallel()

			st := open(t)
			in := []store.IncomingPoint{
				point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", real),
				point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", c.junk),
			}
			stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"})
			if err != nil {
				t.Fatalf("слияние: %v", err)
			}
			// Правило полноты обязано молчать: это столкновение равнополных,
			// а не победа надмножества.
			if stats.Incomparable != 0 {
				t.Errorf("несравнимых %d, ожидалось 0: наборы содержательных ключей равны", stats.Incomparable)
			}
		})
	}
}

// Имя метрики приходит из тела доставки дословно и ничем не ограничено.
// Без обрезки одна доставка порождает WARN-строку в десятки мегабайт и
// вытесняет из ротации логов всю недавнюю историю.
func TestMergeИмяМетрикиВКоординатеОбрезано(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	huge := strings.Repeat("м", 5000)
	in := []store.IncomingPoint{
		point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
		point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`),
	}
	stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"})
	if err != nil {
		t.Fatalf("слияние: %v", err)
	}
	if len(stats.Collisions) == 0 {
		t.Fatal("столкновение не зафиксировано")
	}
	if got := len(stats.Collisions[0].Metric); got > 128 {
		t.Errorf("имя метрики в координате %d байт — оно уедет в лог как есть", got)
	}
}

// Единственное направление, в котором новое правило теряет содержание:
// значения общих содержательных ключей разошлись, разряд полноты погас, и
// победила пришедшая точка, у которой содержательного ключа нет.
//
// До смены тай-брейка исход здесь решал байтовый порядок — то есть та же
// потеря случалась по жребию и так же молча. Разница в том, что теперь она
// детерминирована и СЧИТАЕТСЯ.
func TestMergeПришедшаяУнесшаяСодержаниеСчитаетсяОтдельно(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx := context.Background()

	// `rem` — содержательный ключ сохранённой; у пришедшей его нет, а значение
	// общего ключа `asleep` разошлось, поэтому Relate объявляет пару равнополной.
	stored := point(t, "sleep_analysis_summary", "day", "2025-06-05T00:00:00Z", "2025-06-05T00:00:00Z",
		`{"date":"d","asleep":7.5,"rem":1.2}`)
	arriving := point(t, "sleep_analysis_summary", "day", "2025-06-05T00:00:00Z", "2025-06-05T00:00:00Z",
		`{"date":"d","asleep":7.4}`)

	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{stored}}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{arriving}}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("второе слияние: %v", err)
	}

	b, err := st.Bucket(ctx, "sleep_analysis_summary", "day", ts(t, "2025-06-05T00:00:00Z"))
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if got := string(b.Points[0].Raw); got != string(arriving.Raw) {
		t.Fatalf("в витрине %s, ожидалась пришедшая точка %s", got, arriving.Raw)
	}
	if stats.PointsErased != 1 {
		t.Errorf("потерь содержания %d, ожидалась 1: направление, в котором правило теряет, осталось невидимым", stats.PointsErased)
	}
	if len(stats.PointsErasedAt) != 1 {
		t.Errorf("координат потери %d, ожидалась 1", len(stats.PointsErasedAt))
	}
	if stats.PointsHeld != 0 {
		t.Errorf("удержаний %d: победила пришедшая, удерживать было нечего", stats.PointsHeld)
	}
}

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

	st := open(t)
	ctx := context.Background()

	first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"date":"d","qty":1}`)
	second := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"date":"d","qty":2}`)

	if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
		t.Fatalf("первое слияние: %v", err)
	}
	stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{second}}, store.DeliveryRef{ID: "d2"})
	if err != nil {
		t.Fatalf("второе слияние: %v", err)
	}
	if stats.PointsErased != 0 {
		t.Errorf("потерь содержания %d, ожидалось 0: наборы ключей совпадают, терять нечего", stats.PointsErased)
	}
	if stats.Overwrites != 1 {
		t.Errorf("перезаписей %d, ожидалась 1", stats.Overwrites)
	}
}
