package canon_test

import (
	"bytes"
	"encoding/json"
	"fmt"
	"testing"

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

// Пары взяты с живого потока (docs/research/apple-health.md, находка 30): те же
// измерения в двух выгрузках, разошедшиеся последним разрядом double. Без
// округления 63% повторов считались бы новыми точками.
func TestFormСхлопываетДребезгПоследнегоРазряда(t *testing.T) {
	t.Parallel()

	pairs := []struct {
		name string
		a, b string
	}{
		{
			name: "basal_energy_burned",
			a:    `{"qty":0.09523182962471353}`,
			b:    `{"qty":0.09523182962471352}`,
		},
		{
			name: "active_energy",
			a:    `{"qty":0.0074754192155406605}`,
			b:    `{"qty":0.00747541921554066}`,
		},
		{
			// Тот же случай, вынесенный за пределы двенадцатой значащей цифры
			// явно: граница округления должна работать и на круглых числах.
			name: "граница округления",
			a:    `{"qty":123.4567890123456}`,
			b:    `{"qty":123.4567890123499}`,
		},
	}

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

			ha, err := canon.Hash([]byte(p.a))
			if err != nil {
				t.Fatalf("хеш a: %v", err)
			}
			hb, err := canon.Hash([]byte(p.b))
			if err != nil {
				t.Fatalf("хеш b: %v", err)
			}
			if ha != hb {
				fa, _ := canon.Form([]byte(p.a))
				fb, _ := canon.Form([]byte(p.b))
				t.Errorf("дребезг не схлопнулся:\n  %s\n  %s", fa, fb)
			}
		})
	}
}

// Обратная сторона округления: настоящее различие обязано выжить. Без этого
// теста округление можно было бы «улучшить» до полной бесполезности.
func TestFormСохраняетНастоящееРазличие(t *testing.T) {
	t.Parallel()

	a := []byte(`{"qty":0.09523182962}`)
	b := []byte(`{"qty":0.09523182963}`)

	ha, err := canon.Hash(a)
	if err != nil {
		t.Fatalf("хеш a: %v", err)
	}
	hb, err := canon.Hash(b)
	if err != nil {
		t.Fatalf("хеш b: %v", err)
	}
	if ha == hb {
		t.Error("различие в одиннадцатой значащей цифре съедено округлением")
	}
}

// Порядок ключей в JSON от HAE нестабилен (находка 2): та же точка приезжает с
// разной раскладкой. Идентичность не имеет права от этого зависеть.
func TestFormНеЗависитОтПорядкаКлючей(t *testing.T) {
	t.Parallel()

	a := []byte(`{"date":"2025-06-05 10:00:00 +0300","qty":1.5,"source":"Device A"}`)
	b := []byte(`{"source":"Device A","qty":1.5,"date":"2025-06-05 10:00:00 +0300"}`)

	ha, err := canon.Hash(a)
	if err != nil {
		t.Fatalf("хеш a: %v", err)
	}
	hb, err := canon.Hash(b)
	if err != nil {
		t.Fatalf("хеш b: %v", err)
	}
	if ha != hb {
		t.Error("перестановка ключей изменила идентичность точки")
	}
}

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

	// `1.0` теряет ноль, целое больше 2^53 сдвигается, невалидный UTF-8
	// заменяется на U+FFFD — всё это происходит с КОПИЕЙ внутри canon.
	raw := []byte("{\"exact\":1.0,\"huge\":9007199254740993,\"broken\":\"a\xff\xfeb\"}")
	before := string(raw)

	h1, err := canon.Hash(raw)
	if err != nil {
		t.Fatalf("хеш: %v", err)
	}
	h2, err := canon.Hash(raw)
	if err != nil {
		t.Fatalf("повторный хеш: %v", err)
	}

	if string(raw) != before {
		t.Errorf("исходные байты изменены:\n  было %q\n  стало %q", before, raw)
	}
	if h1 != h2 {
		t.Error("хеш одного и того же значения не воспроизводится")
	}
}

// Целое больше 2^53 не должно проходить через float64 даже внутри
// канонизации: округление сдвинуло бы его, и две разные точки стали бы одной.
func TestFormНеСдвигаетБольшиеЦелые(t *testing.T) {
	t.Parallel()

	a := []byte(`{"id":9007199254740993}`)
	b := []byte(`{"id":9007199254740992}`)

	ha, err := canon.Hash(a)
	if err != nil {
		t.Fatalf("хеш a: %v", err)
	}
	hb, err := canon.Hash(b)
	if err != nil {
		t.Fatalf("хеш b: %v", err)
	}
	if ha == hb {
		t.Error("соседние целые за пределами точности double схлопнулись")
	}

	form, err := canon.Form(a)
	if err != nil {
		t.Fatalf("форма: %v", err)
	}
	if want := `{"id":9007199254740993}`; string(form) != want {
		t.Errorf("целое переписано:\n  получено %s\n  ожидалось %s", form, want)
	}
}

func TestRelateFullness(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name string
		a    string
		b    string
		want canon.Fullness
	}{
		{"одинаковые", `{"qty":1}`, `{"qty":1}`, canon.FullnessEqual},
		{
			"надмножество", `{"qty":1,"context":"x"}`, `{"qty":1}`,
			canon.FullnessSuperset,
		},
		{
			"подмножество", `{"qty":1}`, `{"qty":1,"context":"x"}`,
			canon.FullnessSubset,
		},
		{
			// Тот случай, ради которого правило и переписано: числом ключей он
			// не выражается вовсе.
			name: "несравнимые", a: `{"qty":1}`, b: `{"context":"x"}`,
			want: canon.FullnessIncomparable,
		},
		{
			// source нестабилен и переписывается задним числом, поэтому его
			// наличие ничего не говорит о полноте измерения.
			name: "source не считается",
			a:    `{"qty":1,"source":"Device A"}`, b: `{"qty":1}`,
			want: canon.FullnessEqual,
		},
		{
			// Регрессия задачи: пять полей без содержания против настоящего
			// измерения. Счётчик давал 5 против 2 и стирал измерение.
			name: "поля без содержания не добавляют полноты",
			a:    `{"qty":0,"a":0,"b":0,"c":{},"d":[]}`,
			b:    `{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`,
			want: canon.FullnessSubset,
		},
		{
			// Второй разряд: содержательные ключи те же, но нулевые поля
			// теряться не должны.
			// Второй разряд работает именно при РАВНОМ содержании: qty один и
			// тот же, лишние ключи пусты — терять их незачем.
			name: "при равном содержании выигрывает набор со всеми ключами",
			a:    `{"date":"d","qty":10,"Min":0,"Max":0}`,
			b:    `{"date":"d","qty":10}`,
			want: canon.FullnessSuperset,
		},
		{
			// А вот при РАЗНОМ содержании лишние пустые ключи полноты не дают:
			// точки несут разные измерения, и надмножество имён об этом ничего
			// не говорит. Иначе точка, где ни одно значение не измерение,
			// вытесняла бы настоящее измерение.
			name: "разное содержание не перебивается пустыми ключами",
			a:    `{"date":"d","qty":10,"Min":0,"Max":0}`,
			b:    `{"date":"d","qty":12}`,
			want: canon.FullnessEqual,
		},
		{
			// Тот же дефект в самой опасной форме: падинг из null. Множества
			// содержательных ключей равны, поэтому раньше решал второй разряд —
			// и настоящее измерение проигрывало точке, не несущей измерения.
			name: "падинг из null не полнее измерения",
			a:    `{"date":"d","qty":0.001,"p1":null,"p2":null,"p3":null}`,
			b:    `{"date":"d","qty":72.5}`,
			want: canon.FullnessEqual,
		},
		{
			// Надмножество содержательных ключей тоже обязано СОГЛАСОВЫВАТЬСЯ
			// по общим значениям, иначе `qty:false` побеждало бы `qty:72.5`
			// одним лишь наличием соседних полей.
			name: "надмножество с чужим значением полноты не даёт",
			a:    `{"date":"d","qty":false,"Min":false,"Max":false}`,
			b:    `{"date":"d","qty":72.5}`,
			want: canon.FullnessEqual,
		},
		{
			// Настоящее надмножество: общее значение совпадает, поля добавлены.
			name: "надмножество с тем же значением полнее",
			a:    `{"date":"d","qty":72.5,"Min":70,"Max":75}`,
			b:    `{"date":"d","qty":72.5}`,
			want: canon.FullnessSuperset,
		},
		{
			// false — одно из двух значений булева поля, а не отсутствие
			// сведений: `isIndoor: false` это тренировка на улице.
			name: "false содержателен",
			a:    `{"qty":1,"isIndoor":false}`, b: `{"qty":1}`,
			want: canon.FullnessSuperset,
		},
		{
			// Пустота считается по разобранному значению: будь она побайтовой,
			// эти поля прошли бы как содержательные и набор стал бы несравнимым.
			name: "запись нуля и пустоты роли не играет",
			a:    `{"qty":1,"a":0.0,"b":-0,"c":0e0,"d":{ },"e":[ ],"f":""}`,
			b:    `{"qty":1,"context":"x"}`,
			want: canon.FullnessSubset,
		},
		{
			// Ключ без содержания всё же лучше его отсутствия — но только когда
			// содержательные множества уже сравнялись.
			name: "лишний пустой ключ решает вторым разрядом",
			a:    `{"qty":1,"Min":0}`, b: `{"qty":1}`,
			want: canon.FullnessSuperset,
		},
		{
			// Граница пустоты проведена по содержанию, а не по «похоже на
			// пустое»: пробел, строка "0" и контейнер с элементом — содержание.
			name: "похожее на пустоту содержательно",
			a:    `{"qty":1,"a":" ","b":"0","c":[null],"d":{"x":null}}`,
			b:    `{"qty":1}`,
			want: canon.FullnessSuperset,
		},
		{"не объект против точки", `[1,2,3]`, `{"qty":1}`, canon.FullnessSubset},
		{"оба не объекты", `[1,2,3]`, `"строка"`, canon.FullnessEqual},
		{"невалидный JSON", `{"qty":`, `{"qty":1}`, canon.FullnessSubset},
		{"пустой вход", ``, ``, canon.FullnessEqual},
	}

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

			got := canon.RelateFullness([]byte(c.a), []byte(c.b))
			if got != c.want {
				t.Errorf("полнота %s против %s = %s, ожидалось %s",
					c.a, c.b, got, c.want)
			}

			// Отношение обязано быть симметричным: свёртка по журналу не знает,
			// какая из точек «первая».
			want := mirror(c.want)
			if got := canon.RelateFullness([]byte(c.b), []byte(c.a)); got != want {
				t.Errorf("обратный порядок = %s, ожидалось %s", got, want)
			}
		})
	}
}

func mirror(f canon.Fullness) canon.Fullness {
	switch f {
	case canon.FullnessSuperset:
		return canon.FullnessSubset
	case canon.FullnessSubset:
		return canon.FullnessSuperset
	default:
		return f
	}
}

// Полнота смотрит на наличие содержания, а не на форму записи: значения,
// различающиеся дребезгом последнего разряда, обязаны давать равные множества.
func TestRelateFullnessУстойчивКФормеЗаписи(t *testing.T) {
	t.Parallel()

	a := []byte(`{"qty":0.09523182962471353,"date":"d"}`)
	b := []byte(` { "date" : "d" , "qty" : 0.09523182962471352 } `)

	if got := canon.RelateFullness(a, b); got != canon.FullnessEqual {
		t.Errorf("полнота = %s, ожидалось %s", got, canon.FullnessEqual)
	}
	if !canon.Equal(a, b) {
		t.Error("канонические формы разошлись — тест проверяет не то")
	}
}

// Нулевое значение типа не должно совпадать ни с одним исходом: забытое поле
// или ранний возврат не выглядят как «множества равны».
func TestFullnessНулевоеЗначениеНеИсход(t *testing.T) {
	t.Parallel()

	var zero canon.Fullness
	for _, f := range []canon.Fullness{
		canon.FullnessEqual, canon.FullnessSuperset,
		canon.FullnessSubset, canon.FullnessIncomparable,
	} {
		if f == zero {
			t.Errorf("исход %s совпал с нулевым значением", f)
		}
		if f.String() == zero.String() {
			t.Errorf("имя исхода %s совпало с именем нулевого значения", f)
		}
	}
}

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

	var buf bytes.Buffer
	buf.WriteByte('{')
	for i := range 5000 {
		if i > 0 {
			buf.WriteByte(',')
		}
		fmt.Fprintf(&buf, `"k%d":%d`, i, i)
	}
	buf.WriteByte('}')

	if got := canon.RelateFullness(buf.Bytes(), []byte(`{"k1":1}`)); got != canon.FullnessSuperset {
		t.Errorf("полнота = %s, ожидалось %s", got, canon.FullnessSuperset)
	}
	if got := canon.RelateFullness(nil, nil); got != canon.FullnessEqual {
		t.Errorf("полнота на nil = %s, ожидалось %s", got, canon.FullnessEqual)
	}
}

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

	a := []byte(`{"qty":1,"value":"Во сне"}`)
	b := []byte(`{"value":"В кровати","qty":2}`)

	ab := canon.Less(a, b)
	ba := canon.Less(b, a)

	if ab == ba {
		t.Fatal("порядок не строгий: обе точки считаются меньшими")
	}
	for range 10 {
		if canon.Less(a, b) != ab {
			t.Fatal("порядок не воспроизводится между вызовами")
		}
	}
}

// Каноническая форма — вход хеша, поэтому она обязана оставаться разбираемым
// JSON: иначе отладка столкновения сведётся к чтению байтов глазами.
func TestFormОстаётсяJSON(t *testing.T) {
	t.Parallel()

	raw := []byte(`{"b":[1,2.50,{"z":null,"a":true}],"a":"строка"}`)

	form, err := canon.Form(raw)
	if err != nil {
		t.Fatalf("форма: %v", err)
	}

	var v any
	if err := json.Unmarshal(form, &v); err != nil {
		t.Fatalf("каноническая форма не разбирается: %v\n%s", err, form)
	}
	if want := `{"a":"строка","b":[1,2.5,{"a":true,"z":null}]}`; string(form) != want {
		t.Errorf("форма:\n  получено %s\n  ожидалось %s", form, want)
	}
}

func TestFormОшибкаНаНеJSON(t *testing.T) {
	t.Parallel()

	if _, err := canon.Form([]byte(`{"qty":`)); err == nil {
		t.Error("усечённый JSON принят за корректный")
	}
}

func FuzzForm(f *testing.F) {
	f.Add(`{"qty":1.5}`)
	f.Add(`{"a":{"b":[1,2,3]}}`)
	f.Add(`[]`)
	f.Add(`null`)
	f.Add(`1e400`)
	f.Add("{\"s\":\"\xff\"}")

	f.Fuzz(func(t *testing.T, raw string) {
		// Единственное требование: разбор произвольного входа не роняет
		// процесс. Разбор чужого формата обязан отвечать ошибкой, а не паникой.
		form, err := canon.Form([]byte(raw))
		if err != nil {
			return
		}
		// Каноническая форма корректного входа обязана быть устойчивой:
		// канонизация канонической формы даёт её же.
		again, err := canon.Form(form)
		if err != nil {
			t.Fatalf("каноническая форма не канонизируется повторно: %v\n%s", err, form)
		}
		if string(again) != string(form) {
			t.Fatalf("канонизация не идемпотентна:\n  %s\n  %s", form, again)
		}
	})
}

// covers — сахар для таблиц ниже: Covers работает на разобранных множествах.
func covers(a, b string) bool {
	return canon.Analyze([]byte(a)).Covers(canon.Analyze([]byte(b)))
}

// Покрытие — отношение «не потеряем содержания», и проверяется оно по четырём
// условиям сразу. Оракулы взяты из враждебного прохода ревью: тело, которым
// отправитель управляет целиком, строится так, чтобы пройти проверку и вынести
// маршрут — 95% содержимого тренировки, которого нет в экспорте Apple.
func TestCoversЧетыреУсловия(t *testing.T) {
	t.Parallel()

	const (
		// Настоящая тренировка (форма — из testdata/workout_indoor.json).
		real = `{"id":"w7","name":"В помещении Ходьба","isIndoor":true,
			"maxHeartRate":{"qty":199,"units":"count/min"},
			"heartRate":{"max":{"qty":199},"avg":{"qty":47.2}},
			"heartRateData":[{"Max":199,"Avg":86.1},{"Max":150,"Avg":80.0}],
			"activeEnergy":[{"qty":49.4},{"qty":12.1}],
			"totalEnergy":{"qty":66.4},"duration":11.1}`
		// «Скелет»: те же имена ключей, те же длины массивов, содержания нет.
		skeleton = `{"id":"w7","name":"x","isIndoor":false,
			"maxHeartRate":1,"heartRate":1,
			"heartRateData":[null,null],"activeEnergy":[null,null],
			"totalEnergy":1,"duration":1}`
		route3     = `{"id":"w9","route":[{"lat":1,"lon":10},{"lat":2},{"lat":3}]}`
		routeNull3 = `{"id":"w9","route":[null,null,null]}`
		routeEmpty = `{"id":"w9","route":[{},{},{}]}`
		routeShort = `{"id":"w9","route":[{"lat":1,"lon":10}]}`
		withEmpty  = `{"id":"w9","qty":10,"context":null}`
		noEmpty    = `{"id":"w9","qty":10}`
		richer     = `{"id":"w9","qty":10,"context":null,"stepCount":900}`
	)

	cases := []struct {
		name string
		a, b string
		want bool
	}{
		{"скелет не покрывает настоящую", skeleton, real, false},
		{"настоящая покрывает скелет", real, skeleton, true},
		{"ряд из null не покрывает содержательный", routeNull3, route3, false},
		{"ряд из пустых объектов не покрывает содержательный", routeEmpty, route3, false},
		{"содержательный ряд покрывает пустой той же длины", route3, routeNull3, true},
		{"усечённый ряд не покрывает полный", routeShort, route3, false},
		{"ключ с пустым значением не исчезает", noEmpty, withEmpty, false},
		{"версия с пустым ключом покрывает версию без него", withEmpty, noEmpty, true},
		{"более полная покрывает", richer, withEmpty, true},
		{"менее полная не покрывает", withEmpty, richer, false},
		{"версия покрывает саму себя", real, real, true},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := covers(c.a, c.b); got != c.want {
				t.Errorf("Covers = %v, ожидалось %v", got, c.want)
			}
		})
	}
}

// Запрет вырождения формы: покрывающая версия не может подменить объект или
// массив скаляром. Обратное разрешено — объект вместо скаляра богаче формой.
func TestCoversЗапретВырожденияФормы(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name string
		a, b string
		want bool
	}{
		{"скаляр не покрывает объект", `{"hr":1}`, `{"hr":{"qty":199}}`, false},
		{"скаляр не покрывает массив", `{"hr":1}`, `{"hr":[{"qty":199}]}`, false},
		{"объект не покрывает массив", `{"hr":{"qty":1}}`, `{"hr":[{"qty":1}]}`, false},
		{"массив не покрывает объект", `{"hr":[{"qty":1}]}`, `{"hr":{"qty":1}}`, false},
		{"объект покрывает скаляр", `{"hr":{"qty":1}}`, `{"hr":1}`, true},
		{"строка покрывает число", `{"hr":"x"}`, `{"hr":1}`, true},
		{"пустой ключ формы не требует", `{"hr":0,"id":"a"}`, `{"hr":[],"id":"a"}`, true},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := covers(c.a, c.b); got != c.want {
				t.Errorf("Covers = %v, ожидалось %v", got, c.want)
			}
		})
	}
}

// Покрытие — частичный порядок, и на транзитивности стоит выбор победителя из
// МНОЖЕСТВА версий: без неё «непревзойдённые» определены неоднозначно, и
// победитель становится функцией порядка элементов на проводе.
func TestCoversТранзитивно(t *testing.T) {
	t.Parallel()

	versions := []string{
		`{"id":"w","a":1}`,
		`{"id":"w","a":1,"b":null}`,
		`{"id":"w","a":1,"b":2}`,
		`{"id":"w","a":1,"b":2,"c":[1,2]}`,
		`{"id":"w","a":1,"b":2,"c":[1,2,3]}`,
		`{"id":"w","a":1,"b":2,"c":[null,null,null]}`,
		`{"id":"w","a":1,"c":3}`,
		`{"id":"w"}`,
	}
	for i, x := range versions {
		for j, y := range versions {
			if !covers(x, y) {
				continue
			}
			for k, z := range versions {
				if !covers(y, z) {
					continue
				}
				if !covers(x, z) {
					t.Errorf("нетранзитивно: %d ⊇ %d ⊇ %d, но %d не покрывает %d", i, j, k, i, k)
				}
			}
		}
	}
}

// Форма и хеш обязаны быть одной функцией: сравнение по одной канонизации и
// хеширование по другой разошлись бы молча, а хеш-детектор превратился бы в
// перезапись недели каждым глубоким проходом.
func TestFormAndHashСовпадаетСОтдельнымиВызовами(t *testing.T) {
	t.Parallel()

	raws := []string{
		`{"qty":1.50,"date":"2025-06-05 07:00:00 +0300"}`,
		`{"b":[1,2,{"z":null}],"a":"строка"}`,
		`[1,2,3]`,
		`null`,
	}
	for _, raw := range raws {
		form, hash, err := canon.FormAndHash([]byte(raw))
		if err != nil {
			t.Fatalf("FormAndHash(%s): %v", raw, err)
		}
		wantForm, err := canon.Form([]byte(raw))
		if err != nil {
			t.Fatalf("Form(%s): %v", raw, err)
		}
		wantHash, err := canon.Hash([]byte(raw))
		if err != nil {
			t.Fatalf("Hash(%s): %v", raw, err)
		}
		if !bytes.Equal(form, wantForm) {
			t.Errorf("форма разошлась: %s против %s", form, wantForm)
		}
		if hash != wantHash {
			t.Errorf("хеш разошёлся: %s против %s", hash, wantHash)
		}
	}

	if _, _, err := canon.FormAndHash([]byte(`{"qty":`)); err == nil {
		t.Error("усечённый JSON принят за корректный")
	}
}
