package store_test

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

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

func fingerprint(t *testing.T, st *store.Store) string {
	t.Helper()

	fp, err := st.Fingerprint(context.Background())
	if err != nil {
		t.Fatalf("отпечаток: %v", err)
	}
	return fp
}

// workout собирает тренировку с заданным содержимым. Заголовок в этих тестах
// вторичен: правило замены смотрит на содержание, а не на колонки.
func workout(t *testing.T, id, raw string) store.IncomingEntity {
	t.Helper()

	return store.IncomingEntity{
		ID:            id,
		Kind:          "workouts",
		Name:          "На улице Ходьба",
		Start:         ts(t, "2025-06-05T07:00:00Z"),
		End:           ts(t, "2025-06-05T07:10:00Z"),
		OffsetSeconds: 3 * 3600,
		Raw:           json.RawMessage(raw),
	}
}

func from(t *testing.T, id, receivedAt string) store.DeliveryRef {
	t.Helper()

	return store.DeliveryRef{ID: id, ReceivedAt: ts(t, receivedAt)}
}

func mergeWorkouts(t *testing.T, st *store.Store, d store.DeliveryRef, ws ...store.IncomingEntity) store.MergeStats {
	t.Helper()

	stats, err := st.Merge(context.Background(), store.Incoming{Workouts: ws}, d)
	if err != nil {
		t.Fatalf("слияние сущностей: %v", err)
	}
	return stats
}

const (
	// Содержимое подобрано так, чтобы отличаться от прежнего ЗНАЧЕНИЯМИ общих
	// полей: именно так тренировка и меняется между версиями (досчёт энергии),
	// и именно на этом ломается правило полноты, написанное для точек.
	woWithRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
		"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
	woNoRouteNewValues = `{"id":"w1","name":"На улице Ходьба",
		"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
	woShortRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1}],
		"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
	woRicher = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
		"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"stepCount":{"qty":900}}`
	woSameShapeNewValues = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
		"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
	woIncomparable = `{"id":"w1","name":"На улице Ходьба",
		"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"flightsClimbed":{"qty":3}}`
)

func storedRaw(t *testing.T, st *store.Store, id string) string {
	t.Helper()

	w, err := st.Workout(context.Background(), id)
	if err != nil {
		t.Fatalf("чтение тренировки: %v", err)
	}
	return string(w.Raw)
}

// Единственная причина повторной присылки тренировки — доезжающий маршрут.
func TestMergeДоехавшийМаршрутЗамещаетТренировку(t *testing.T) {
	t.Parallel()

	st := open(t)
	mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woNoRouteNewValues))
	stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woWithRoute))

	if stats.WorkoutsWritten != 1 {
		t.Errorf("записано %d, ожидалась 1: версия с маршрутом полнее", stats.WorkoutsWritten)
	}
	if stats.EntitiesHeld != 0 {
		t.Errorf("удержано %d, ожидалось 0", stats.EntitiesHeld)
	}
	if got := storedRaw(t, st, "w1"); got != woWithRoute {
		t.Error("в витрине не версия с маршрутом")
	}
}

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

	st := open(t)
	mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
	stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))

	if stats.WorkoutsWritten != 1 {
		t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten)
	}
	if got := storedRaw(t, st, "w1"); got != woSameShapeNewValues {
		t.Error("досчитанная версия не легла в витрину")
	}
}

// Исход обязан быть функцией ЖУРНАЛА, а не порядка свёртки: воркер сворачивает
// в порядке журнала только среди видимых ему доставок, и доставка с более
// ранней меткой может свернуться позже.
func TestMergeВерсияИзБолееРаннейДоставкиНеОткатываетВитрину(t *testing.T) {
	t.Parallel()

	early := from(t, "d1", "2025-06-05T08:00:00Z")
	late := from(t, "d2", "2025-06-05T08:05:00Z")

	прямой := open(t)
	mergeWorkouts(t, прямой, early, workout(t, "w1", woWithRoute))
	mergeWorkouts(t, прямой, late, workout(t, "w1", woSameShapeNewValues))

	обратный := open(t)
	mergeWorkouts(t, обратный, late, workout(t, "w1", woSameShapeNewValues))
	mergeWorkouts(t, обратный, early, workout(t, "w1", woWithRoute))

	a, b := storedRaw(t, прямой, "w1"), storedRaw(t, обратный, "w1")
	if a != b {
		t.Error("исход зависит от порядка свёртки — живая витрина разойдётся с пересборкой")
	}
	if a != woSameShapeNewValues {
		t.Error("победила версия не из более поздней доставки журнала")
	}
}

// Маршрут — 95% содержимого тренировки, а восстановление требует пересборки
// всего журнала. Событие делается наблюдаемым, а не необратимым.
func TestMergeОбеднённаяВерсияНеЗатираетСохранённую(t *testing.T) {
	t.Parallel()

	st := open(t)
	mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
	stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woNoRouteNewValues))

	if stats.WorkoutsWritten != 0 {
		t.Errorf("записано %d, ожидалось 0: приехавшая теряет маршрут", stats.WorkoutsWritten)
	}
	if stats.EntitiesHeld != 1 {
		t.Errorf("удержано %d, ожидалась 1 — событие обязано быть видно", stats.EntitiesHeld)
	}
	if len(stats.HeldAt) != 1 || stats.HeldAt[0].ID != "w1" {
		t.Errorf("координаты удержанной версии %v", stats.HeldAt)
	}
	if got := storedRaw(t, st, "w1"); got != woWithRoute {
		t.Error("маршрут пропал из витрины")
	}
}

// Усечённый маршрут ключа не теряет — множеств ключей мало.
func TestMergeУсечённыйМаршрутНеЗатираетСохранённый(t *testing.T) {
	t.Parallel()

	st := open(t)
	mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
	stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woShortRoute))

	if stats.EntitiesHeld != 1 {
		t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld)
	}
	if got := storedRaw(t, st, "w1"); got != woWithRoute {
		t.Error("полный маршрут вытеснен усечённым")
	}
}

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

	st := open(t)
	mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
	stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woIncomparable))

	if stats.EntitiesHeld != 1 {
		t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld)
	}
	if got := storedRaw(t, st, "w1"); got != woWithRoute {
		t.Error("несравнимая версия заместила сохранённую")
	}
}

// Хеш — детектор изменений: тренировка переприсылается каждой доставкой, пока
// не доедет маршрут, и 41 копия из 44 записи вызывать не должна.
func TestMergeПовторТойЖеТренировкиНеПишет(t *testing.T) {
	t.Parallel()

	st := open(t)
	mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))

	// Тот же смысл, другой порядок ключей и дребезг литерала: каноническая
	// форма обязана совпасть.
	same := `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0},
		"activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
	stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", same))

	if stats.WorkoutsWritten != 0 {
		t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten)
	}
}

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

	type step struct {
		d   store.DeliveryRef
		raw string
	}
	// Четвёртая версия несёт содержимое, РАВНОЕ одной из уже присланных. Без неё
	// перебор троек с разными хешами ветку «содержание равно» не посещает ни
	// разу — а именно на ней провенанс устаревал, и живая витрина расходилась с
	// пересборкой молча.
	steps := []step{
		{from(t, "d1", "2025-06-05T08:00:00Z"), woNoRouteNewValues},
		{from(t, "d2", "2025-06-05T08:05:00Z"), woWithRoute},
		{from(t, "d3", "2025-06-05T08:10:00Z"), woRicher},
		{from(t, "d4", "2025-06-05T08:15:00Z"), woWithRoute},
	}
	orders := [][]int{
		{0, 1, 2, 3}, {3, 2, 1, 0}, {1, 0, 3, 2},
		{2, 3, 0, 1}, {1, 3, 0, 2}, {3, 0, 2, 1},
	}

	var want string
	for i, order := range orders {
		st := open(t)
		for _, idx := range order {
			mergeWorkouts(t, st, steps[idx].d, workout(t, "w1", steps[idx].raw))
		}
		got := storedRaw(t, st, "w1")
		if i == 0 {
			want = got
			if want != woRicher {
				t.Fatalf("победила не самая полная версия")
			}
			continue
		}
		if got != want {
			t.Errorf("порядок %v дал другое состояние", order)
		}
	}
}

// Порядок элементов в JSON-массиве нестабилен, поэтому две версии одного ключа
// внутри одной доставки не имеют права разрешаться «последним в массиве».
func TestMergeДвеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) {
	t.Parallel()

	d := from(t, "d1", "2025-06-05T08:00:00Z")

	прямой := open(t)
	mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameShapeNewValues))

	обратный := open(t)
	mergeWorkouts(t, обратный, d, workout(t, "w1", woSameShapeNewValues), workout(t, "w1", woWithRoute))

	if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
		t.Error("исход зависит от порядка элементов в массиве секции")
	}
}

// Записи разных родов с одним идентификатором — разные записи: ключ пара, а не
// один id.
func TestMergeЗаписиРазныхРодовСОднимIDНеСталкиваются(t *testing.T) {
	t.Parallel()

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

	mind := store.IncomingEntity{
		ID: "e1", Kind: "stateOfMind",
		Start: ts(t, "2025-06-05T18:00:00Z"),
		End:   ts(t, "2025-06-05T18:00:00Z"),
		Raw:   json.RawMessage(`{"id":"e1","kind":"momentary_emotion","valence":0.5}`),
	}
	ecg := store.IncomingEntity{
		ID: "e1", Kind: "ecg",
		Start: ts(t, "2025-06-05T19:00:00Z"),
		End:   ts(t, "2025-06-05T19:00:00Z"),
		Raw:   json.RawMessage(`{"id":"e1","classification":"sinusRhythm"}`),
	}
	if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{mind, ecg}},
		from(t, "d1", "2025-06-05T20:00:00Z")); err != nil {
		t.Fatalf("слияние записей: %v", err)
	}

	for _, kind := range []string{"stateOfMind", "ecg"} {
		if _, err := st.Record(ctx, kind, "e1"); err != nil {
			t.Errorf("запись рода %q не найдена: %v", kind, err)
		}
	}
}

// Отпечаток — единственный оракул сходимости. Витрины, совпадающие по часовым
// объектам, но разошедшиеся в тренировке, обязаны давать разные отпечатки.
func TestFingerprintРазличаетТренировки(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	build := func(raw string) string {
		st := open(t)
		in := store.Incoming{
			Points:   []store.IncomingPoint{point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)},
			Workouts: []store.IncomingEntity{workout(t, "w1", raw)},
		}
		if _, err := st.Merge(ctx, in, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
			t.Fatalf("слияние: %v", err)
		}
		fp, err := st.Fingerprint(ctx)
		if err != nil {
			t.Fatalf("отпечаток: %v", err)
		}
		return fp
	}

	full, changed := build(woWithRoute), build(woSameShapeNewValues)
	if full == changed {
		t.Error("отпечатки совпали при разошедшемся содержимом тренировки")
	}
	if again := build(woWithRoute); again != full {
		t.Error("отпечаток не воспроизводится на одном содержимом")
	}
}

// Длительность: ноль — законное измерение, а «не прислали» обязано быть
// отличимо от него.
func TestWorkoutДлительностьОтличаетНольОтОтсутствия(t *testing.T) {
	t.Parallel()

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

	zero := 0.0
	withZero := workout(t, "w-zero", `{"id":"w-zero","qty":1}`)
	withZero.Duration = &zero
	withNone := workout(t, "w-none", `{"id":"w-none","qty":1}`)

	if _, err := st.Merge(ctx, store.Incoming{Workouts: []store.IncomingEntity{withZero, withNone}},
		from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
		t.Fatalf("слияние: %v", err)
	}

	w, err := st.Workout(ctx, "w-zero")
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if w.Duration == nil || *w.Duration != 0 {
		t.Errorf("нулевая длительность потерялась: %v", w.Duration)
	}
	w, err = st.Workout(ctx, "w-none")
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if w.Duration != nil {
		t.Errorf("отсутствие длительности стало значением %v", *w.Duration)
	}
}

// Содержимое сущности хранится дословно: побайтовый круг «запись → чтение».
func TestWorkoutСодержимоеХранитсяДословно(t *testing.T) {
	t.Parallel()

	st := open(t)
	// Литералы, которые теряет любая пересборка через разобранные значения:
	// `1.0`, целое больше 2^53, дробь длиннее двенадцати значащих цифр, а также
	// символы, которые json.Marshal экранирует по умолчанию.
	const raw = `{"id":"w9","route":[{"lat":1.0,"big":9007199254740993,"p":0.123456789012345678}],
		"note":"a<b&c>d","isIndoor":false}`

	if _, err := st.Merge(context.Background(),
		store.Incoming{Workouts: []store.IncomingEntity{workout(t, "w9", raw)}},
		from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
		t.Fatalf("слияние: %v", err)
	}
	if got := storedRaw(t, st, "w9"); got != raw {
		t.Errorf("содержимое изменилось при хранении:\nбыло  %s\nстало %s", raw, got)
	}
}

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

	st := open(t)
	mergeWorkouts(t, st, from(t, "d7", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))

	w, err := st.Workout(context.Background(), "w1")
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	if w.Delivery != "d7" {
		t.Errorf("провенанс %q, ожидался d7", w.Delivery)
	}
	if w.Start.IsZero() || w.End.Before(w.Start) {
		t.Errorf("интервал заголовка неверен: %v — %v", w.Start, w.End)
	}
	if w.OffsetSeconds != 3*3600 {
		t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds)
	}
}

// Внутри одной доставки «сохранённой» версии не существует — есть только
// порядок элементов в JSON-массиве, а он нестабилен. Ни исход, ни счётчик не
// имеют права от него зависеть.
func TestMergeНесравнимыеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) {
	t.Parallel()

	d := from(t, "d1", "2025-06-05T08:00:00Z")
	const withRoute = `{"id":"w1","route":[{"lat":1}],"stepCount":{"qty":9}}`
	const withFlights = `{"id":"w1","route":[{"lat":1}],"flightsClimbed":{"qty":3}}`

	прямой := open(t)
	a := mergeWorkouts(t, прямой, d, workout(t, "w1", withRoute), workout(t, "w1", withFlights))

	обратный := open(t)
	b := mergeWorkouts(t, обратный, d, workout(t, "w1", withFlights), workout(t, "w1", withRoute))

	if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
		t.Error("исход зависит от порядка элементов в массиве секции")
	}
	if a.EntitiesDiverging != b.EntitiesDiverging {
		t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesDiverging, b.EntitiesDiverging)
	}
	if a.EntitiesDiverging == 0 {
		t.Error("две версии с разным содержанием в одном теле остались незамеченными")
	}
	// Удержаний тут нет: победитель лёг в витрину целиком, терять нечего.
	// Счётчики разведены именно ради этого различия.
	if a.EntitiesHeld != 0 || b.EntitiesHeld != 0 {
		t.Errorf("несравнимые версии в одном теле посчитаны удержаниями: %d и %d",
			a.EntitiesHeld, b.EntitiesHeld)
	}
}

// Отпечаток обязан различать состояния, а не только содержимое: составной ключ
// записи, склеенный до взятия длины, даёт (`a`, `b/c`) = (`a/b`, `c`).
func TestFingerprintРазличаетСоставнойКлючЗаписи(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	build := func(kind, id string) string {
		st := open(t)
		rec := store.IncomingEntity{
			ID: id, Kind: kind,
			Start: ts(t, "2025-06-05T18:00:00Z"),
			End:   ts(t, "2025-06-05T18:00:00Z"),
			Raw:   json.RawMessage(`{"id":"x","valence":0.5}`),
		}
		if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec}},
			from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
			t.Fatalf("слияние: %v", err)
		}
		fp, err := st.Fingerprint(ctx)
		if err != nil {
			t.Fatalf("отпечаток: %v", err)
		}
		return fp
	}

	if build("a", "b/c") == build("a/b", "c") {
		t.Error("два разных состояния витрины дали один отпечаток: составной ключ склеен до взятия длины")
	}
}

// Оракулы враждебного прохода ревью: тело контролирует отправитель целиком, и
// «версия той же формы без содержания» проходила все проверки — а маршрут это
// 95% тренировки, которого в экспорте Apple нет вовсе. Восстановить его после
// затирания не может даже пересборка: журнал проиграет то же поражение.
const (
	woRealWorkout = `{"id":"w7","name":"В помещении Ходьба","isIndoor":true,
		"maxHeartRate":{"qty":199,"units":"count/min"},
		"heartRate":{"max":{"qty":199},"avg":{"qty":47.2},"min":{"qty":41}},
		"heartRateData":[{"Max":199,"Avg":86.1,"Min":41},{"Max":150,"Avg":80.0,"Min":44}],
		"activeEnergy":[{"qty":49.4,"units":"kJ"},{"qty":12.1,"units":"kJ"}],
		"totalEnergy":{"qty":66.4,"units":"kJ"},"duration":11.1}`
	woSkeleton = `{"id":"w7","name":"x","isIndoor":false,
		"maxHeartRate":1,"heartRate":1,
		"heartRateData":[null,null],"activeEnergy":[null,null],
		"totalEnergy":1,"duration":1}`
	woNulledRoute = `{"id":"w1","name":"На улице Ходьба","route":[null,null,null],
		"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
	woEmptyRoute = `{"id":"w1","name":"На улице Ходьба","route":[{},{},{}],
		"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
	// Тот же смысл, другой порядок ключей и другая запись числа: каноническая
	// форма совпадает, байты — нет.
	woSameFormOtherBytes = `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0},
		"activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
)

func TestMergeСкелетНеВытесняетТренировку(t *testing.T) {
	t.Parallel()

	st := open(t)
	mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w7", woRealWorkout))
	stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w7", woSkeleton))

	if got := storedRaw(t, st, "w7"); !strings.Contains(got, "199") {
		t.Error("тренировка заменена скелетом из скаляров и null")
	}
	if stats.EntitiesHeld != 1 {
		t.Errorf("удержано %d, ожидалось 1: событие обязано быть видно", stats.EntitiesHeld)
	}
}

func TestMergeМаршрутНеЗатираетсяПустышкамиТойЖеДлины(t *testing.T) {
	t.Parallel()

	for name, poor := range map[string]string{"null": woNulledRoute, "пустые объекты": woEmptyRoute} {
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			st := open(t)
			mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
			stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", poor))

			if got := storedRaw(t, st, "w1"); !strings.Contains(got, `"lat":1`) {
				t.Error("маршрут затёрт рядом той же длины без содержания")
			}
			if stats.EntitiesHeld != 1 {
				t.Errorf("удержано %d, ожидалось 1", stats.EntitiesHeld)
			}
		})
	}
}

// Ключ с пустым значением исчезал бы по жребию тай-брейка. Второй разряд
// сравнения записан для точек и здесь применяется к сущностям.
func TestMergeКлючСПустымЗначениемНеИсчезает(t *testing.T) {
	t.Parallel()

	const (
		withEmpty = `{"id":"w1","qty":10,"context":null}`
		noEmpty   = `{"id":"w1","qty":11}`
	)
	st := open(t)
	mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", withEmpty))
	stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", noEmpty))

	if got := storedRaw(t, st, "w1"); !strings.Contains(got, "context") {
		t.Error("ключ с пустым значением исчез по жребию")
	}
	if stats.EntitiesHeld != 1 {
		t.Errorf("удержано %d, ожидалось 1", stats.EntitiesHeld)
	}
}

// Две версии одного `id` в ОДНОМ теле с разным содержанием: счётчик не имеет
// права молчать. До правки он давал ноль — то есть событие проходило бы как
// штатное INFO.
func TestMergeДвеВерсииВОдномТелеСчитаются(t *testing.T) {
	t.Parallel()

	d := from(t, "d1", "2025-06-05T08:00:00Z")
	st := open(t)
	stats := mergeWorkouts(t, st, d, workout(t, "w1", woWithRoute), workout(t, "w1", woNulledRoute))

	if stats.EntitiesDiverging == 0 {
		t.Error("две версии одного id в одном теле — счётчик молчит")
	}
	if got := storedRaw(t, st, "w1"); !strings.Contains(got, `"lat":1`) {
		t.Error("в теле победила версия без содержания")
	}
}

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

	d := from(t, "d1", "2025-06-05T08:00:00Z")

	прямой := open(t)
	s1 := mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameFormOtherBytes))
	обратный := open(t)
	s2 := mergeWorkouts(t, обратный, d, workout(t, "w1", woSameFormOtherBytes), workout(t, "w1", woWithRoute))

	if s1.EntitiesDiverging != 0 || s2.EntitiesDiverging != 0 {
		t.Errorf("счётчик сработал на дребезге записи: %d и %d",
			s1.EntitiesDiverging, s2.EntitiesDiverging)
	}
	if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
		t.Error("в витрине разные байты при одинаковом содержимом: победитель зависит от порядка")
	}
}

// Победитель внутри доставки — функция МНОЖЕСТВА версий. Попарная свёртка
// частичного порядка с тотальным тай-брейком нетранзитивна: [A,B,C] давало C,
// [B,C,A] давало A.
func TestMergeПерестановкаТрёхВерсийВОдномТеле(t *testing.T) {
	t.Parallel()

	const (
		vA = `{"a":[9,9],"b":2,"id":"k"}`
		vB = `{"a":[1,2],"id":"k"}`
		vC = `{"a":[1],"c":3,"id":"k"}`
	)
	orders := [][]string{
		{vA, vB, vC}, {vA, vC, vB}, {vB, vA, vC},
		{vB, vC, vA}, {vC, vA, vB}, {vC, vB, vA},
	}

	var want string
	for i, order := range orders {
		st := open(t)
		ws := make([]store.IncomingEntity, 0, len(order))
		for _, raw := range order {
			ws = append(ws, workout(t, "k", raw))
		}
		mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), ws...)

		got := storedRaw(t, st, "k")
		if i == 0 {
			want = got
			continue
		}
		if got != want {
			t.Errorf("порядок %d дал другого победителя:\n  %s\n  %s", i, got, want)
		}
	}
}

// Провенанс обязан отражать победителя ЖУРНАЛА, а не первую свёрнутую копию.
// Иначе доставка, свёрнутая с опозданием, вернёт витрину к прежнему содержимому,
// и живая витрина разойдётся с пересборкой молча — в содержимом тренировки.
func TestMergeОтложеннаяДоставкаНеВозвращаетПрежнееСодержимое(t *testing.T) {
	t.Parallel()

	journal := open(t)
	mergeWorkouts(t, journal, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
	mergeWorkouts(t, journal, from(t, "d3", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))
	mergeWorkouts(t, journal, from(t, "d2", "2025-06-05T08:10:00Z"), workout(t, "w1", woWithRoute))

	deferred := open(t)
	mergeWorkouts(t, deferred, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
	mergeWorkouts(t, deferred, from(t, "d2", "2025-06-05T08:10:00Z"), workout(t, "w1", woWithRoute))
	mergeWorkouts(t, deferred, from(t, "d3", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))

	if a, b := storedRaw(t, journal, "w1"), storedRaw(t, deferred, "w1"); a != b {
		t.Errorf("состояние зависит от порядка свёртки:\n  %s\n  %s", a, b)
	}
	if a, b := fingerprint(t, journal), fingerprint(t, deferred); a != b {
		t.Errorf("отпечатки разошлись: %s против %s", a, b)
	}
}

// Провенанс поднимается до более поздней доставки журнала даже при совпавшем
// хеше — иначе тай-брейк «содержание равно» решает по устаревшей позиции.
func TestMergeПовторОбновляетПровенанс(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	st := open(t)
	mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
	stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woWithRoute))

	if stats.WorkoutsWritten != 0 {
		t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten)
	}
	w, err := st.Workout(ctx, "w1")
	if err != nil {
		t.Fatalf("чтение тренировки: %v", err)
	}
	if w.Delivery != "d2" {
		t.Errorf("провенанс %q, ожидался d2: победитель журнала — более поздняя доставка", w.Delivery)
	}
}

// Провенанс записи обновляется тем же путём, что и у тренировки, но ключ у неё
// СОСТАВНОЙ — а значит текст `WHERE` и хвост аргументов обязаны совпадать. Ветка
// не покрывалась ни одним тестом, притом что промах давал бы `UPDATE` в ноль
// строк, невидимый ни в отпечатке (провенанса там нет), ни в счётчиках.
// Род `record` — единственный, где цена необратима: в экспорте Apple его нет.
func TestMergeПровенансЗаписиОбновляетсяПоСоставномуКлючу(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	st := open(t)
	rec := func() store.IncomingEntity {
		return store.IncomingEntity{
			ID: "e1", Kind: "stateOfMind",
			Start: ts(t, "2025-06-05T18:00:00Z"),
			End:   ts(t, "2025-06-05T18:00:00Z"),
			Raw:   json.RawMessage(`{"id":"e1","kind":"momentary_emotion","valence":0.5}`),
		}
	}
	merge := func(d store.DeliveryRef) store.MergeStats {
		t.Helper()
		s, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec()}}, d)
		if err != nil {
			t.Fatalf("слияние записи: %v", err)
		}
		return s
	}

	merge(from(t, "d1", "2025-06-05T20:00:00Z"))
	stats := merge(from(t, "d2", "2025-06-05T20:05:00Z"))
	if stats.RecordsWritten != 0 {
		t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.RecordsWritten)
	}

	got, err := st.Record(ctx, "stateOfMind", "e1")
	if err != nil {
		t.Fatalf("чтение записи: %v", err)
	}
	if got.Delivery != "d2" {
		t.Errorf("провенанс %q, ожидался d2", got.Delivery)
	}
}

// Число версий одного ключа приходит из чужого тела, а выбор победителя по ним
// квадратичен. Отмена обязана прерывать отбор: без неё тело с двадцатью
// тысячами версий занимает единственного воркера свёртки дольше, чем длится его
// собственный дедлайн, и очередь встаёт молча при зелёном `/healthz`.
func TestMergeОтменаПрерываетВыборПобедителя(t *testing.T) {
	t.Parallel()

	st := open(t)
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	ws := make([]store.IncomingEntity, 0, 3)
	for i := range 3 {
		raw := fmt.Sprintf(`{"id":"w1","qty":%d,"route":[{"lat":%d}]}`, i, i)
		ws = append(ws, workout(t, "w1", raw))
	}
	_, err := st.Merge(ctx, store.Incoming{Workouts: ws}, from(t, "d1", "2025-06-05T08:00:00Z"))
	if !errors.Is(err, context.Canceled) {
		t.Errorf("слияние дало %v, ожидалась отмена", err)
	}
}

// Версии с совпавшей канонической формой схлопываются ДО квадратичного отбора:
// иначе тело, вмещающее сотни тысяч копий одного `id`, стоит часов работы. При
// этом схлопывание не имеет права менять исход — победитель тот же.
func TestMergeРавныеФормыСхлопываютсяДоОтбора(t *testing.T) {
	t.Parallel()

	st := open(t)
	ws := make([]store.IncomingEntity, 0, 2000)
	for range 2000 {
		ws = append(ws, workout(t, "w1", woWithRoute))
	}
	ws = append(ws, workout(t, "w1", woRicher))

	done := make(chan store.MergeStats, 1)
	go func() {
		done <- mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), ws...)
	}()
	select {
	case stats := <-done:
		if got := storedRaw(t, st, "w1"); got != woRicher {
			t.Errorf("схлопывание изменило победителя:\n%s", got)
		}
		// Победитель — единственная более полная версия; от неё формой
		// отличаются две тысячи копий победнее, и схлопывание их не прячет:
		// счётчик считает версии, а не группы.
		if stats.EntitiesDiverging != 2000 {
			t.Errorf("различающихся версий %d, ожидалось 2000", stats.EntitiesDiverging)
		}
	case <-time.After(20 * time.Second):
		t.Fatal("слияние не уложилось в 20 с: схлопывание не работает")
	}
}

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

	const (
		сПустымКлючом = `{"id":"w2","activeEnergy":[{"qty":10}],"totalEnergy":null}`
		сМаршрутом    = `{"id":"w2","activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
	)
	st := open(t)
	mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w2", сПустымКлючом))
	stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w2", сМаршрутом))

	if got := storedRaw(t, st, "w2"); !strings.Contains(got, `"lat":1`) {
		t.Errorf("маршрут не доехал — досчёт заперт пустым ключом:\n%s", got)
	}
	if stats.WorkoutsWritten != 1 {
		t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten)
	}
	if stats.EntitiesHeld != 0 {
		t.Errorf("удержано %d: законный досчёт принят за потерю содержания", stats.EntitiesHeld)
	}
}

// Точка, где витрина сущностей НЕ является функцией множества доставок, —
// несравнимые версии. Тест не чинит это, а закрепляет: в витрине лежит
// победитель прошлых слияний, а не все кандидаты истории, поэтому «сохранённая
// выигрывает» даёт разный итог при разном порядке.
//
// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше не
// перегоняется своими преемницами — проход воркера прекращается на ней.
// Осталось окно конкурентного приёма, где строка учёта становится видимой позже
// метки. У точек единственной такой точки давно нет: там от порядка журнала
// зависит весь тай-брейк равной полноты, и это объявлено вслух.
//
// Предел назван в pickEntity и в architecture.md; наблюдается счётчиком
// удержаний. Если он когда-нибудь будет закрыт, красный тест напомнит, что
// текст в обоих местах пора переписать.
func TestMergeНесравнимыеВерсииЗависятОтПорядкаСвёртки(t *testing.T) {
	t.Parallel()

	const (
		база     = `{"id":"w3","activeEnergy":[{"qty":10}]}`
		сТреком  = `{"id":"w3","activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2}]}`
		сЭтажами = `{"id":"w3","activeEnergy":[{"qty":10}],"flightsClimbed":{"qty":3}}`
	)
	d1 := from(t, "d1", "2025-06-05T08:00:00Z")
	d2 := from(t, "d2", "2025-06-05T08:05:00Z")
	d3 := from(t, "d3", "2025-06-05T08:10:00Z")

	журнальный := open(t)
	mergeWorkouts(t, журнальный, d1, workout(t, "w3", база))
	mergeWorkouts(t, журнальный, d2, workout(t, "w3", сТреком))
	mergeWorkouts(t, журнальный, d3, workout(t, "w3", сЭтажами))

	отложенный := open(t)
	mergeWorkouts(t, отложенный, d1, workout(t, "w3", база))
	mergeWorkouts(t, отложенный, d3, workout(t, "w3", сЭтажами))
	stats := mergeWorkouts(t, отложенный, d2, workout(t, "w3", сТреком))

	a, b := storedRaw(t, журнальный, "w3"), storedRaw(t, отложенный, "w3")
	if a == b {
		t.Fatal("предел закрылся — перепиши текст в pickEntity и architecture.md")
	}
	// И главное: расхождение не молчит.
	if stats.EntitiesHeld == 0 {
		t.Error("расхождение по порядку свёртки не отражено счётчиком удержаний")
	}
}
