package hae_test

import (
	"fmt"
	"runtime"
	"strings"
	"testing"
	"time"

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

// Тела доходили до 42 МиБ, и стратегия декодирования — часть контракта, а не
// деталь: разбор такого тела в map[string]any удерживает 197 МиБ кучи против
// 54 МиБ у формы с json.RawMessage. Вместе с самим телом и удвоением в чтении
// пик доходил бы до ~300 МиБ на доставку — это OOM ровно на пике потока, когда
// терять доставки дороже всего.
//
// Тест сторожит именно порядок величины, а не точное число: он обязан падать
// при возврате к map[string]any и не падать от смены версии Go.
func TestParseУдержаниеКучи(t *testing.T) {
	if testing.Short() {
		t.Skip("измерение кучи: не для -short")
	}

	body := largeBody(40, 4000)
	t.Logf("тело %d МиБ", len(body)>>20)

	var before, after runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&before)

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

	runtime.GC()
	runtime.ReadMemStats(&after)

	retained := int64(after.HeapAlloc) - int64(before.HeapAlloc)
	limit := int64(len(body)) * 4

	t.Logf("точек %d, удержано %d МиБ при теле %d МиБ",
		len(res.Points), retained>>20, len(body)>>20)

	if retained > limit {
		t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+
			"похоже, точки снова разбираются в map[string]any",
			retained>>20, len(body)>>20)
	}

	// Результат обязан дожить до замера, иначе мерить нечего.
	runtime.KeepAlive(res)
	runtime.KeepAlive(body)
}

// largeBody собирает тело, похожее на настоящее: те же поля, тот же формат
// метки, минутное выравнивание.
func largeBody(metrics, points int) []byte {
	var b strings.Builder
	b.WriteString(`{"data":{"metrics":[`)

	base := time.Date(2025, 6, 5, 0, 0, 0, 0, time.FixedZone("", 3*3600))
	for m := range metrics {
		if m > 0 {
			b.WriteByte(',')
		}
		fmt.Fprintf(&b, `{"name":"metric_%d","units":"count","data":[`, m)
		for p := range points {
			if p > 0 {
				b.WriteByte(',')
			}
			ts := base.Add(time.Duration(p) * time.Minute).Format("2006-01-02 15:04:05 -0700")
			fmt.Fprintf(&b,
				`{"date":"%s","qty":%d.09523182962471353,"source":"Device A|Device B"}`,
				ts, p%97)
		}
		b.WriteString(`]}`)
	}

	b.WriteString(`]}}`)
	return []byte(b.String())
}

// Перечисление непокрытых секций не имеет права стать вторым способом удержать
// тело. Непокрытая секция ПРОГЛАТЫВАЕТСЯ декодированием в выбрасываемый
// RawMessage: копия одна, живёт до следующего члена, удерживается ноль. Разбор
// `data` в map[string]json.RawMessage дешевле по коду, но копирует байты всех
// секций и держит их до конца разбора — этот тест сторожит разницу.
func TestParseУдержаниеКучиНепокрытойСекции(t *testing.T) {
	if testing.Short() {
		t.Skip("измерение кучи: не для -short")
	}

	body := bodyWithUncovered(40 << 20)
	t.Logf("тело %d МиБ, из них непокрытая секция почти всё", len(body)>>20)

	var before, after runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&before)

	// Слой единственной точке взяться неоткуда — наследуем, иначе разбор
	// откажет раньше, чем дойдёт до замера.
	res, err := hae.Parse(body, hae.Meta{FallbackLayer: hae.LayerMinute})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}

	runtime.GC()
	runtime.ReadMemStats(&after)

	retained := int64(after.HeapAlloc) - int64(before.HeapAlloc)
	limit := int64(len(body)) * 4

	t.Logf("непокрытых %v, удержано %d МиБ при теле %d МиБ",
		res.Uncovered, retained>>20, len(body)>>20)

	// Замер обязан идти по ветке проглатывания. Без этой проверки расширение
	// множества покрытых секций превращает сторож в зелёную пустышку — что уже
	// однажды и произошло.
	if len(res.Uncovered) == 0 {
		t.Fatal("непокрытых секций нет — замер идёт мимо проглатывания и ничего не сторожит")
	}

	if retained > limit {
		t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+
			"похоже, секции удерживаются, а не проглатываются",
			retained>>20, len(body)>>20)
	}

	runtime.KeepAlive(res)
	runtime.KeepAlive(body)
}

// Тело из вложенных скобок обязано быть ОТВЕРГНУТО, а не съедено. Пропуск
// ручным счётом глубины по Token() этого не давал: делимитеры идут мимо
// сканера, ограничитель вложенности encoding/json не работает, и стек токенов
// растёт как O(глубины) — измерено 488 МиБ пика на теле 40 МиБ.
func TestParseВложенныеСкобкиОтвергаются(t *testing.T) {
	t.Parallel()

	const depth = 100_000
	var b strings.Builder
	b.WriteString(`{"data":{"deep":`)
	b.WriteString(strings.Repeat("[", depth))
	b.WriteString(strings.Repeat("]", depth))
	b.WriteString(`}}`)

	if _, err := hae.Parse([]byte(b.String()), hae.Meta{}); err == nil {
		t.Error("тело из ста тысяч уровней вложенности принято — ограничитель вложенности не работает")
	}
}

func bodyWithUncovered(size int) []byte {
	var b strings.Builder
	b.WriteString(`{"data":{"metrics":[{"name":"m","units":"count","data":[`)
	b.WriteString(`{"date":"2025-06-05 10:00:00 +0300","qty":1}`)
	// Секция обязана быть ЗАВЕДОМО НЕПОКРЫТОЙ: тело из покрытой секции идёт
	// мимо проглатывания, и замер вырождается в ноль, оставаясь зелёным.
	// Так уже случилось однажды: здесь стоял `stateOfMind`, и задача, покрывшая
	// его разбором, обезоружила сторож молча.
	b.WriteString(`]}],"ecg":[`)

	const entry = `{"id":"00000000-0000-0000-0000-000000000000","classification":"sinusRhythm"},`
	for b.Len() < size {
		b.WriteString(entry)
	}
	b.WriteString(`{"id":"tail"}]}}`)
	return []byte(b.String())
}
