package hae_test

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"testing"
	"time"
	"unicode/utf8"

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

func load(t *testing.T, name string) []byte {
	t.Helper()

	body, err := os.ReadFile(filepath.Join("testdata", name))
	if err != nil {
		t.Fatalf("фикстура %s: %v", name, err)
	}
	return body
}

// layersOf собирает слой по имени метрики: слой обязан выводиться ПО МЕТРИКЕ,
// а не по доставке, поэтому сверять его надо тоже по метрике.
func layersOf(points []hae.Point) map[string]hae.Layer {
	out := make(map[string]hae.Layer)
	for _, p := range points {
		out[p.Metric] = p.Layer
	}
	return out
}

func countsOf(points []hae.Point) map[string]int {
	out := make(map[string]int)
	for _, p := range points {
		out[p.Metric]++
	}
	return out
}

// Фикстуры собраны из живого архива с вычищенными значениями: выравнивание
// меток, порядок ключей и форма литералов сохранены (см. testdata/README.md).
func TestParseФикстурыЖивогоПотока(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name    string
		file    string
		meta    hae.Meta
		layer   hae.Layer
		metrics map[string]hae.Layer
	}{
		{
			name:  "минутная доставка",
			file:  "minute.json",
			meta:  hae.Meta{Aggregation: "Minutes"},
			layer: hae.LayerMinute,
			metrics: map[string]hae.Layer{
				"active_energy":                    hae.LayerMinute,
				"heart_rate_variability":           hae.LayerMinute,
				"apple_stand_hour":                 hae.LayerHour,   // плотная и своя
				"sleep_analysis_summary":           hae.LayerDay,    // слой назначен схемой
				"six_minute_walking_test_distance": hae.LayerMinute, // редкая наследует
			},
		},
		{
			name:  "часовая доставка",
			file:  "hour.json",
			meta:  hae.Meta{Aggregation: "Hours"},
			layer: hae.LayerHour,
			metrics: map[string]hae.Layer{
				"active_energy":          hae.LayerHour,
				"apple_stand_hour":       hae.LayerHour,
				"sleep_analysis_summary": hae.LayerDay,
			},
		},
		{
			name:  "посекундная доставка",
			file:  "raw.json",
			meta:  hae.Meta{Aggregation: "Default"},
			layer: hae.LayerRaw,
			metrics: map[string]hae.Layer{
				"heart_rate":         hae.LayerRaw,
				"resting_heart_rate": hae.LayerRaw,
			},
		},
		{
			// Перенастройка автоматизации: в одной доставке минутные,
			// посекундные и часовые метрики. Отнесение доставки к одному слою
			// сложило бы минутные точки с посекундными.
			name:  "смешанная доставка",
			file:  "mixed.json",
			meta:  hae.Meta{Aggregation: "Default"},
			layer: hae.LayerRaw,
			metrics: map[string]hae.Layer{
				"heart_rate":             hae.LayerRaw,
				"active_energy":          hae.LayerMinute,
				"apple_stand_hour":       hae.LayerHour,
				"sleep_analysis_summary": hae.LayerDay,
				"resting_heart_rate":     hae.LayerRaw, // редкая наследует слой доставки
			},
		},
	}

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

			res, err := hae.Parse(load(t, c.file), c.meta)
			if err != nil {
				t.Fatalf("разбор: %v", err)
			}
			if res.Layer != c.layer {
				t.Errorf("слой доставки = %q, ожидался %q", res.Layer, c.layer)
			}

			got := layersOf(res.Points)
			for metric, want := range c.metrics {
				if got[metric] != want {
					t.Errorf("%s: слой %q, ожидался %q", metric, got[metric], want)
				}
			}
			if res.SkippedMalformed != 0 {
				t.Errorf("живая фикстура дала %d неразобранных точек", res.SkippedMalformed)
			}
		})
	}
}

// Под именем sleep_analysis приезжают две несовместимые схемы. Разделение
// выполняется ДО вывода слоя: сводок бывает больше порога плотности, и их
// полуночные метки иначе назначили бы всей доставке слой hour.
func TestParseРазводитДвеСхемыСна(t *testing.T) {
	t.Parallel()

	res, err := hae.Parse(load(t, "minute.json"), hae.Meta{Aggregation: "Minutes"})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}

	counts := countsOf(res.Points)
	if counts["sleep_analysis_summary"] == 0 {
		t.Fatal("суточная сводка сна не выделена в собственную метрику")
	}
	if counts["sleep_analysis"] != 0 {
		t.Errorf("под именем sleep_analysis осталось %d точек сводки", counts["sleep_analysis"])
	}

	for _, p := range res.Points {
		if p.Metric == "sleep_analysis_summary" && p.Layer != hae.LayerDay {
			t.Errorf("слой сводки = %q, ожидался day", p.Layer)
		}
	}
}

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

	res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}

	var intervals []hae.Point
	for _, p := range res.Points {
		if p.Metric == "interval_points" {
			intervals = append(intervals, p)
		}
	}
	if len(intervals) != 3 {
		t.Fatalf("точек-интервалов %d, ожидалось 3", len(intervals))
	}

	starts := map[time.Time]int{}
	ends := map[time.Time]int{}
	for _, p := range intervals {
		starts[p.Start]++
		ends[p.End]++
		if !p.End.After(p.Start) {
			t.Errorf("конец %v не позже начала %v", p.End, p.Start)
		}
	}
	if len(starts) != 1 {
		t.Errorf("начал %d, ожидалось одно общее", len(starts))
	}
	if len(ends) != 3 {
		t.Errorf("концов %d, ожидалось три разных", len(ends))
	}
}

// Точка без end адресуется вырожденным интервалом: ключ одной формы для всех
// точек, без ветвления по «классу метрики».
func TestParseТочкаБезКонцаВырожденнаяВИнтервал(t *testing.T) {
	t.Parallel()

	res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}

	found := false
	for _, p := range res.Points {
		if p.Metric != "mixed_alignment" {
			continue
		}
		found = true
		if !p.End.Equal(p.Start) {
			t.Fatalf("конец %v не равен началу %v у точки-измерения", p.End, p.Start)
		}
	}
	if !found {
		t.Fatal("метрика mixed_alignment не разобрана")
	}
}

// Метка ровно на часе одновременно является и минутной, поэтому слой метрики —
// самое мелкое встретившееся выравнивание, а не преобладающее.
func TestParseСлойПоСамомуМелкомуВыравниванию(t *testing.T) {
	t.Parallel()

	res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}

	if got := layersOf(res.Points)["mixed_alignment"]; got != hae.LayerMinute {
		t.Errorf("слой = %q, ожидался minute: десять меток часовые, одна минутная", got)
	}
}

// Незнакомое поле — норма: HAE меняется между версиями, и точка обязана
// доехать до хранилища дословно, вместе с тем, чего разбор не понимает.
func TestParseСохраняетТочкуДословно(t *testing.T) {
	t.Parallel()

	body := load(t, "handmade_edge.json")
	res, err := hae.Parse(body, hae.Meta{})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}

	var raw string
	for _, p := range res.Points {
		if p.Metric == "unknown_shape" {
			raw = string(p.Raw)
		}
	}
	if raw == "" {
		t.Fatal("метрика unknown_shape не разобрана")
	}

	for _, want := range []string{
		`"inventedField"`,      // незнакомое поле целиком
		`"nested"`,             // вместе с вложенной структурой
		`1.0`,                  // литерал, который round-trip превратил бы в 1
		`9007199254740993`,     // целое за пределами точности double
		`0.095231829624713534`, // дробь длиннее двенадцати значащих цифр
	} {
		if !strings.Contains(raw, want) {
			t.Errorf("в сохранённой точке нет %s:\n%s", want, raw)
		}
	}
}

// Серия ударов — 93% объёма метрики heart_rate_variability и третий формат
// времени. Разворачивать её в точки нельзя, преобразовывать эпоху — тоже:
// time.Unix и обратно не гарантирует дословности.
func TestParseНеТрогаетСериюУдаров(t *testing.T) {
	t.Parallel()

	res, err := hae.Parse(load(t, "heartbeat_series.json"), hae.Meta{FallbackLayer: hae.LayerRaw})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}

	if len(res.Points) != 2 {
		t.Fatalf("точек %d, ожидалось 2: серия не должна разворачиваться", len(res.Points))
	}

	var series []struct {
		Date json.Number `json:"date"`
	}
	var point struct {
		Series json.RawMessage `json:"heartbeatSeries"`
	}
	if err := json.Unmarshal(res.Points[0].Raw, &point); err != nil {
		t.Fatalf("точка не разбирается: %v", err)
	}
	if len(point.Series) == 0 {
		t.Fatal("в точке нет heartbeatSeries")
	}
	dec := json.NewDecoder(strings.NewReader(string(point.Series)))
	dec.UseNumber()
	if err := dec.Decode(&series); err != nil {
		t.Fatalf("серия не разбирается: %v", err)
	}
	if len(series) == 0 {
		t.Fatal("серия пуста")
	}
	// Эпоха внутри серии осталась дробным числом, а не превратилась в строку
	// или в целое: разбор её не касался.
	if !strings.Contains(series[0].Date.String(), ".") {
		t.Errorf("эпоха внутри серии изменена: %s", series[0].Date)
	}
}

// Доставка без плотных метрик: измерено 2 такие из 89, обе с заголовком
// Default. Молчаливый выбор raw создал бы призрачный разрез, который поедет в
// каталог и в правило Read API «самый мелкий слой, покрывающий диапазон».
func TestParseДоставкаБезПлотныхМетрик(t *testing.T) {
	t.Parallel()

	body := load(t, "sparse_sleep.json")

	t.Run("наследовать нечего и заголовок ненадёжен", func(t *testing.T) {
		t.Parallel()

		res, err := hae.Parse(body, hae.Meta{Aggregation: "Default"})
		if !errors.Is(err, hae.ErrLayerUnknown) {
			t.Fatalf("ошибка %v, ожидалась ErrLayerUnknown", err)
		}
		if len(res.Points) != 0 {
			t.Errorf("сохранено %d точек при неизвестном слое", len(res.Points))
		}
	})

	t.Run("наследует слой автоматизации", func(t *testing.T) {
		t.Parallel()

		res, err := hae.Parse(body, hae.Meta{Aggregation: "Default", FallbackLayer: hae.LayerMinute})
		if err != nil {
			t.Fatalf("разбор: %v", err)
		}
		if res.Layer != hae.LayerMinute {
			t.Errorf("слой = %q, ожидался унаследованный minute", res.Layer)
		}
	})

	t.Run("надёжный заголовок как последнее средство", func(t *testing.T) {
		t.Parallel()

		res, err := hae.Parse(body, hae.Meta{Aggregation: "Hours"})
		if err != nil {
			t.Fatalf("разбор: %v", err)
		}
		if res.Layer != hae.LayerHour {
			t.Errorf("слой = %q, ожидался hour из заголовка", res.Layer)
		}
	})
}

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

	body := load(t, "raw.json") // выводится raw

	res, err := hae.Parse(body, hae.Meta{Aggregation: "Default"})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}
	if res.LayerMismatch {
		t.Error("расхождение зафиксировано против заголовка Default")
	}

	res, err = hae.Parse(body, hae.Meta{Aggregation: "Hours"})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}
	if !res.LayerMismatch {
		t.Error("расхождение с надёжным заголовком Hours не зафиксировано")
	}
	if res.Layer != hae.LayerRaw {
		t.Errorf("слой = %q: сохранять надо по выведенному, а не по заголовку", res.Layer)
	}
}

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

	res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}

	if res.SkippedNoTime != 3 {
		t.Errorf("точек без метки %d, ожидалось 3", res.SkippedNoTime)
	}
	if res.SkippedMalformed != 1 {
		t.Errorf("неразобранных точек %d, ожидалась 1", res.SkippedMalformed)
	}
	if countsOf(res.Points)["broken_timestamps"] != 0 {
		t.Error("точка с непонятной меткой попала в хранилище")
	}
}

// Время нормализовано в UTC, но зона не потеряна: без офсета неизвестно, в
// каком поясе человек находился.
func TestParseСохраняетСмещениеЗоны(t *testing.T) {
	t.Parallel()

	res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}

	for _, p := range res.Points {
		if p.Metric != "mixed_alignment" {
			continue
		}
		if p.Start.Location() != time.UTC {
			t.Errorf("время не в UTC: %v", p.Start)
		}
		if p.OffsetSeconds != 3*3600 {
			t.Errorf("смещение %d секунд, ожидалось %d", p.OffsetSeconds, 3*3600)
		}
		return
	}
	t.Fatal("метрика mixed_alignment не разобрана")
}

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

	cases := []struct {
		name    string
		body    string
		wantErr error
	}{
		{"усечённое тело", `{"data":{"metrics":[`, hae.ErrMalformed},
		{"не JSON вовсе", `не json`, hae.ErrMalformed},
		{"пустое тело", ``, hae.ErrMalformed},
		{"null вместо объекта", `null`, nil},
		{"массив вместо data", `{"data":[1,2,3]}`, hae.ErrMalformed},
		{"массив вместо metrics", `{"data":{"metrics":{}}}`, hae.ErrMalformed},
		{"метрики отсутствуют", `{"data":{"workouts":[]}}`, nil},
		{"точка не объект", `{"data":{"metrics":[{"name":"m","data":[1,2]}]}}`, nil},
		{"число вместо строки даты", `{"data":{"metrics":[{"name":"m","data":[{"date":1}]}]}}`, nil},
		{"дата не того формата", `{"data":{"metrics":[{"name":"m","data":[{"date":"2025-06-05T10:00:00Z"}]}]}}`, nil},
	}

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

			_, err := hae.Parse([]byte(c.body), hae.Meta{})
			if c.wantErr == nil {
				if err != nil && !errors.Is(err, hae.ErrLayerUnknown) {
					t.Fatalf("неожиданная ошибка: %v", err)
				}
				return
			}
			if !errors.Is(err, c.wantErr) {
				t.Fatalf("ошибка %v, ожидалась %v", err, c.wantErr)
			}
		})
	}
}

// Разбор — чистая функция: ни времени, ни генерации идентификаторов. Два
// вызова на одном входе обязаны совпасть, иначе пересборка витрины из архива
// даст не то состояние, что живой приём.
func TestParseДетерминирован(t *testing.T) {
	t.Parallel()

	body := load(t, "mixed.json")
	meta := hae.Meta{Aggregation: "Default"}

	a, err := hae.Parse(body, meta)
	if err != nil {
		t.Fatalf("первый разбор: %v", err)
	}
	b, err := hae.Parse(body, meta)
	if err != nil {
		t.Fatalf("второй разбор: %v", err)
	}

	if len(a.Points) != len(b.Points) {
		t.Fatalf("точек %d против %d", len(a.Points), len(b.Points))
	}
	for i := range a.Points {
		if a.Points[i].Metric != b.Points[i].Metric ||
			!a.Points[i].Start.Equal(b.Points[i].Start) ||
			!a.Points[i].End.Equal(b.Points[i].End) ||
			a.Points[i].Layer != b.Points[i].Layer ||
			string(a.Points[i].Raw) != string(b.Points[i].Raw) {
			t.Fatalf("точка %d разошлась между вызовами", i)
		}
	}
}

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

	body := load(t, "mixed.json")
	before := string(body)

	if _, err := hae.Parse(body, hae.Meta{Aggregation: "Default"}); err != nil {
		t.Fatalf("разбор: %v", err)
	}
	if string(body) != before {
		t.Error("разбор изменил тело доставки")
	}
}

func FuzzParse(f *testing.F) {
	for _, name := range []string{"minute.json", "sparse_sleep.json", "handmade_edge.json"} {
		body, err := os.ReadFile(filepath.Join("testdata", name))
		if err != nil {
			f.Fatalf("фикстура %s: %v", name, err)
		}
		f.Add(body)
	}
	f.Add([]byte(`{"data":{"metrics":[{"name":"m","units":"u","data":[{"date":"2025-06-05 10:00:00 +0300"}]}]}}`))

	f.Fuzz(func(t *testing.T, body []byte) {
		// Единственное требование: разбор произвольного входа не роняет
		// процесс и не отдаёт точку без координат.
		res, err := hae.Parse(body, hae.Meta{})
		if err != nil {
			return
		}
		for _, p := range res.Points {
			if p.Start.IsZero() {
				t.Fatal("точка без метки времени доехала до хранилища")
			}
			if p.End.Before(p.Start) {
				t.Fatalf("конец раньше начала: %v < %v", p.End, p.Start)
			}
			if p.Layer == "" {
				t.Fatal("точка без слоя доехала до хранилища")
			}
		}
	})
}

// Непокрытая секция неотличима от разобранной доставки с пустой секцией
// метрик, если её не назвать: ретеншен, ориентируясь на статус, срезал бы
// тела, которые для секций без экспорта Apple единственный источник. С тех пор
// как workouts и stateOfMind стали покрытыми, роль непокрытой в фикстуре
// играют секции, которых поток ещё не приносил.
func TestParseПеречисляетНепокрытыеСекции(t *testing.T) {
	t.Parallel()

	res, err := hae.Parse(load(t, "uncovered_sections.json"), hae.Meta{})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}

	want := []string{"ecg", "symptoms"}
	if !slices.Equal(res.Uncovered, want) {
		t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want)
	}
	if len(res.Points) == 0 {
		t.Error("точки метрик обязаны сохраниться: непокрытая секция рядом им не мешает")
	}
	if res.UncoveredDropped != 0 {
		t.Errorf("отброшено %d имён, ожидалось 0", res.UncoveredDropped)
	}
}

func TestParseНепокрытыеСекцииГраницыИДетерминизм(t *testing.T) {
	t.Parallel()

	t.Run("доставка из одной непокрытой секции", func(t *testing.T) {
		t.Parallel()

		res, err := hae.Parse([]byte(`{"data":{"ecg":[{"x":1}]}}`), hae.Meta{})
		if err != nil {
			t.Fatalf("разбор: %v", err)
		}
		if !slices.Equal(res.Uncovered, []string{"ecg"}) {
			t.Errorf("непокрытые %v", res.Uncovered)
		}
		if len(res.Points) != 0 {
			t.Errorf("точек %d, ожидалось 0", len(res.Points))
		}
	})

	t.Run("доставка из одних метрик списка не даёт", func(t *testing.T) {
		t.Parallel()

		res, err := hae.Parse([]byte(`{"data":{"metrics":[]}}`), hae.Meta{})
		if err != nil {
			t.Fatalf("разбор: %v", err)
		}
		if len(res.Uncovered) != 0 {
			t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
		}
	})

	// Порядок ключей в JSON от HAE нестабилен, а список уезжает в базу и
	// сравнивается между доставками: зависящий от порядка на проводе список
	// сравнивать нельзя.
	t.Run("порядок и повторы не влияют", func(t *testing.T) {
		t.Parallel()

		bodies := []string{
			`{"data":{"symptoms":[],"medications":[],"ecg":[]}}`,
			`{"data":{"ecg":[],"symptoms":[],"medications":[]}}`,
			`{"data":{"medications":[],"ecg":[],"symptoms":[],"ecg":[]}}`,
		}
		want := []string{"ecg", "medications", "symptoms"}
		for _, b := range bodies {
			res, err := hae.Parse([]byte(b), hae.Meta{})
			if err != nil {
				t.Fatalf("разбор %s: %v", b, err)
			}
			if !slices.Equal(res.Uncovered, want) {
				t.Errorf("тело %s дало %v, ожидалось %v", b, res.Uncovered, want)
			}
		}
	})

	// Повтор ключа metrics JSON допускает; секции объединяются, а не побеждает
	// последняя — терять точки молча нельзя.
	t.Run("повтор metrics объединяет секции", func(t *testing.T) {
		t.Parallel()

		const body = `{"data":{"metrics":[{"name":"a","data":[]}],"metrics":[{"name":"b","data":[]}]}}`
		res, err := hae.Parse([]byte(body), hae.Meta{})
		if err != nil {
			t.Fatalf("разбор: %v", err)
		}
		if res.Metrics != 2 {
			t.Errorf("метрик %d, ожидалось 2 — секции обязаны объединиться", res.Metrics)
		}
	})

	t.Run("границы списка видны", func(t *testing.T) {
		t.Parallel()

		var sb strings.Builder
		sb.WriteString(`{"data":{`)
		for i := range 40 {
			if i > 0 {
				sb.WriteByte(',')
			}
			fmt.Fprintf(&sb, `"s%02d":[]`, i)
		}
		sb.WriteString(`}}`)

		res, err := hae.Parse([]byte(sb.String()), hae.Meta{})
		if err != nil {
			t.Fatalf("разбор: %v", err)
		}
		if len(res.Uncovered) != 32 {
			t.Errorf("имён %d, ожидалось 32", len(res.Uncovered))
		}
		if res.UncoveredDropped != 8 {
			t.Errorf("отброшено %d, ожидалось 8 — иначе «ровно 32» неотличимо от «пришло пятьсот»", res.UncoveredDropped)
		}
	})

	t.Run("длинное имя обрезано и помечено", func(t *testing.T) {
		t.Parallel()

		long := strings.Repeat("щ", 60) // 120 байт
		res, err := hae.Parse([]byte(`{"data":{"`+long+`":[]}}`), hae.Meta{})
		if err != nil {
			t.Fatalf("разбор: %v", err)
		}
		if len(res.Uncovered) != 1 {
			t.Fatalf("непокрытые %v", res.Uncovered)
		}
		got := res.Uncovered[0]
		if !strings.HasSuffix(got, "…") {
			t.Errorf("имя %q без маркера обрезки: обрезка не инъективна и обязана быть видимой", got)
		}
		if !utf8.ValidString(got) {
			t.Errorf("имя %q обрезано посреди руны", got)
		}
	})

	// Ошибка после разобранной секции metrics: правило прежнее — при ошибке
	// точек нет вовсе, иначе часть точек оказалась бы в витрине под статусом,
	// по которому доставку никто не подберёт.
	t.Run("обрыв после метрик точек не отдаёт", func(t *testing.T) {
		t.Parallel()

		const body = `{"data":{"metrics":[{"name":"a","data":[{"date":"2025-06-05 10:00:00 +0300","qty":1}]}],"work`
		res, err := hae.Parse([]byte(body), hae.Meta{})
		if !errors.Is(err, hae.ErrMalformed) {
			t.Fatalf("ошибка %v, ожидалась ErrMalformed", err)
		}
		if len(res.Points) != 0 {
			t.Errorf("точек %d, ожидалось 0", len(res.Points))
		}
	})
}

// Инвариант «тела запросов только на DEBUG и с обрезкой» обходился одним `%v`:
// строка в 8 МиБ на месте ожидаемого объекта давала текст ошибки в 8 МиБ, и он
// уезжал атрибутом `error` на уровень WARN — то есть содержимое доставки
// оказывалось в логе целиком и без обрезки.
//
// Предел держит само сообщение, а не обрезка на стороне логирующего: обрезка
// живёт в другом месте и о новой ошибке разбора не узнает.
func TestParseОшибкаНеНесётЗначенийИзТела(t *testing.T) {
	t.Parallel()

	const secret = "СЕКРЕТНОЕ-ЗНАЧЕНИЕ-ИЗ-ТЕЛА"
	cases := map[string]string{
		"строка вместо data":        `{"data":"` + secret + strings.Repeat("A", 1<<20) + `"}`,
		"строка вместо тела":        `"` + secret + strings.Repeat("A", 1<<20) + `"`,
		"число вместо имени секции": `{"data":{"metrics":[]},"` + secret + `":1}`,
		"число вместо содержимого":  `{"data":{"metrics":` + strings.Repeat("9", 1<<20) + `}}`,
	}
	for name, body := range cases {
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			_, err := hae.Parse([]byte(body), hae.Meta{})
			if err == nil {
				t.Skip("вход разобрался — проверять нечего")
			}
			msg := err.Error()
			if len(msg) > 512 {
				t.Errorf("текст ошибки %d Б: длина зависит от длины значения во входе", len(msg))
			}
			if strings.Contains(msg, secret) {
				t.Errorf("значение из тела доехало до сообщения: %s", msg)
			}
		})
	}
}

// Смещение обязано указывать на место ПЕРЕД виновным токеном, а не за ним.
// Декодер сообщает позицию как конец последнего возвращённого токена, поэтому
// снятая ПОСЛЕ чтения она отличалась бы от начала проблемы ровно на длину
// значения — на восемь мегабайт в том самом случае, ради которого требование и
// написано. Проверяется само свойство: смещение не зависит от длины значения.
func TestParseОшибкаНазываетТипТокенаИНачало(t *testing.T) {
	t.Parallel()

	short := `{"data":"` + strings.Repeat("A", 16) + `"}`
	long := `{"data":"` + strings.Repeat("A", 1<<20) + `"}`

	msgs := make([]string, 0, 2)
	for _, body := range []string{short, long} {
		_, err := hae.Parse([]byte(body), hae.Meta{})
		if err == nil {
			t.Fatal("ожидалась ошибка")
		}
		msgs = append(msgs, err.Error())
	}

	if !strings.Contains(msgs[0], "string") {
		t.Errorf("тип токена не назван словарём JSON: %s", msgs[0])
	}
	// `{"data"` — семь байт: разбор виновного значения начинается здесь.
	if !strings.Contains(msgs[0], "смещении 7") {
		t.Errorf("смещение не указывает на место перед токеном: %s", msgs[0])
	}
	if msgs[0] != msgs[1] {
		t.Errorf("смещение поехало вместе с длиной значения:\n  %s\n  %s", msgs[0], msgs[1])
	}
}

// Ошибка может прийти не только от нашего разбора, но и из encoding/json, а его
// UnmarshalTypeError кладёт в текст ЛИТЕРАЛ значения: тело из миллиона цифр
// давало текст ошибки в мегабайт. Предел держится на границе пакета.
func TestParseЧужаяПричинаОбрезается(t *testing.T) {
	t.Parallel()

	body := `{"data":{"metrics":` + strings.Repeat("9", 1<<20) + `}}`
	_, err := hae.Parse([]byte(body), hae.Meta{})
	if err == nil {
		t.Fatal("ожидалась ошибка")
	}
	if len(err.Error()) > 512 {
		t.Errorf("текст ошибки %d Б: литерал из тела доехал до сообщения", len(err.Error()))
	}
}

// Словарь слоёв ОДИН, и порядок в нём — от самого мелкого к самому крупному.
//
// Утверждается прямо, потому что из этого словаря выводятся четыре вещи: ранг
// слоя при разборе, перечень слоёв для выборки охватов при чтении, проверка
// параметра запроса и текст отказа клиенту. Разъехавшись, они дали бы пустой
// ряд при непустых данных — и ни компилятор, ни другой тест этого не увидели бы.
func TestСловарьСлоёвУпорядоченОтМелкогоККрупному(t *testing.T) {
	want := []hae.Layer{hae.LayerSample, hae.LayerRaw, hae.LayerMinute, hae.LayerHour, hae.LayerDay}

	if len(hae.Layers) != len(want) {
		t.Fatalf("слоёв в словаре %d, ожидалось %d", len(hae.Layers), len(want))
	}
	for i, l := range want {
		if hae.Layers[i] != l {
			t.Errorf("слой %d — %q, ожидался %q", i, hae.Layers[i], l)
		}
		if hae.Rank(l) != i {
			t.Errorf("ранг %q = %d, ожидался %d", l, hae.Rank(l), i)
		}
		if !hae.Known(l) {
			t.Errorf("слой %q словарём не признан", l)
		}
	}

	// Незнакомый слой крупнее любого известного и словарём не признан: иначе он
	// выиграл бы предпочтение при равном охвате.
	if got := hae.Rank("weekly"); got != len(hae.Layers) {
		t.Errorf("ранг незнакомого слоя %d, ожидался %d", got, len(hae.Layers))
	}
	if hae.Known("weekly") {
		t.Error("незнакомый слой признан словарём")
	}
}
