package fold_test

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"path/filepath"
	"strings"
	"testing"

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

// Данные о здоровье чувствительнее токенов, и требование «значения точек не в
// логах» до сих пор не проверялось ничем: все тестовые логгеры выбрасывали
// записи. Тест ловит записи и смотрит на них.
func TestFoldНеПишетЗначенийВЛог(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))

	dir := t.TempDir()
	arch, err := archive.New(filepath.Join(dir, "raw"))
	if err != nil {
		t.Fatalf("архив: %v", err)
	}
	st, err := store.Open(filepath.Join(dir, "healthlog.db"))
	if err != nil {
		t.Fatalf("база: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })

	f := fold.New(arch, st, 0, log)

	// Значения и имя устройства выбраны так, чтобы их нельзя было спутать ни с
	// чем: если они окажутся в логе, это будет видно.
	body := []byte(`{"data":{"metrics":[{"name":"step_count","units":"count","data":[` +
		`{"date":"2025-06-05 10:00:00 +0300","qty":424242.7,"source":"Секретные Часы Антона"},` +
		`{"date":"2025-06-05 10:01:00 +0300","qty":313131.9,"source":"Секретные Часы Антона"},` +
		`{"date":"2025-06-05 10:02:00 +0300","qty":151515.1,"source":"Секретные Часы Антона"}` +
		`]}]}}`)

	deliver(t, arch, st, "d1", "Minutes", "auto-1", body)
	if _, err := f.Fold(context.Background(), "d1"); err != nil {
		t.Fatalf("свёртка: %v", err)
	}

	logged := buf.String()
	if logged == "" {
		t.Fatal("свёртка не записала ни одной строки — чекпоинт молчит")
	}

	for _, secret := range []string{"424242", "313131", "151515", "Секретные Часы"} {
		if strings.Contains(logged, secret) {
			t.Errorf("в логе оказалось %q:\n%s", secret, logged)
		}
	}

	// И одновременно — чекпоинт обязан нести счётчики, иначе он бесполезен.
	var rec map[string]any
	for line := range strings.SplitSeq(strings.TrimSpace(logged), "\n") {
		if err := json.Unmarshal([]byte(line), &rec); err != nil {
			t.Fatalf("строка лога не JSON: %v", err)
		}
		if rec["msg"] == "delivery folded" {
			break
		}
	}
	for _, attr := range []string{"delivery_id", "points", "stored", "buckets", "layer", "skipped"} {
		if _, ok := rec[attr]; !ok {
			t.Errorf("в записи нет атрибута %q: %v", attr, rec)
		}
	}
}

// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
// штатная работа. Без WARN смена формата метки выглядела бы как здоровый
// поток: 200, parsed, INFO, points=0.
func TestFoldВсеТочкиОтброшеныДаётWarn(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))

	dir := t.TempDir()
	arch, err := archive.New(filepath.Join(dir, "raw"))
	if err != nil {
		t.Fatalf("архив: %v", err)
	}
	st, err := store.Open(filepath.Join(dir, "healthlog.db"))
	if err != nil {
		t.Fatalf("база: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })

	f := fold.New(arch, st, 0, log)

	// Метки в формате, которого разбор не знает: HAE сменил формат.
	body := []byte(`{"data":{"metrics":[{"name":"step_count","units":"count","data":[` +
		`{"date":"2025-06-05T10:00:00Z","qty":1},{"date":"2025-06-05T10:01:00Z","qty":2}` +
		`]}]}}`)

	deliver(t, arch, st, "d1", "Minutes", "auto-1", body)
	if _, err := f.Fold(context.Background(), "d1"); err != nil {
		t.Fatalf("свёртка: %v", err)
	}

	if !strings.Contains(buf.String(), `"level":"WARN"`) {
		t.Errorf("доставка без единой сохранённой точки записана не как WARN:\n%s", buf.String())
	}
}

// Несравнимые наборы полей — то самое событие, ради наблюдения за которым
// объединение полей не реализовано вовсе. Если оно когда-нибудь наступит, его
// обязано быть видно, а не додумано задним числом.
func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))

	dir := t.TempDir()
	arch, err := archive.New(filepath.Join(dir, "raw"))
	if err != nil {
		t.Fatalf("архив: %v", err)
	}
	st, err := store.Open(filepath.Join(dir, "healthlog.db"))
	if err != nil {
		t.Fatalf("база: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })

	f := fold.New(arch, st, 0, log)
	ctx := context.Background()

	const body = `{"data":{"metrics":[{"name":"blood_glucose","units":"mg/dL","data":[` +
		`{"date":"2025-06-05 10:00:00 +0300",%s}` +
		`]}]}}`

	deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":5.1`)))
	if _, err := f.Fold(ctx, "d1"); err != nil {
		t.Fatalf("первая свёртка: %v", err)
	}
	buf.Reset()

	deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"mealTime":"До еды"`)))
	stats, err := f.Fold(ctx, "d2")
	if err != nil {
		t.Fatalf("вторая свёртка: %v", err)
	}
	if stats.Incomparable != 1 {
		t.Fatalf("несравнимых %d, ожидался 1: тест проверяет не то", stats.Incomparable)
	}

	var rec map[string]any
	if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &rec); err != nil {
		t.Fatalf("строка лога не JSON: %v\n%s", err, buf.String())
	}
	if rec["level"] != "WARN" {
		t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String())
	}
	if rec["msg"] != "delivery folded, incomparable point fields" {
		t.Errorf("сообщение %v не называет событие", rec["msg"])
	}
	if rec["incomparable"] != float64(1) {
		t.Errorf("счётчик несравнимых %v, ожидался 1", rec["incomparable"])
	}
	// Координаты объекта — не значения: метрика, слой и час.
	at, _ := rec["incomparable_at"].(string)
	if !strings.Contains(at, "blood_glucose/minute@") {
		t.Errorf("координаты объекта в записи не те: %q", at)
	}
	// И ни одного значения точки: данные о здоровье чувствительнее токенов.
	//
	// Метка времени из проверки исключается, и это не поблажка: она содержит
	// доли секунды, поэтому подстрока вроде "5.1" находится в ней примерно раз
	// на сотню прогонов — тест краснел от хода часов, а не от утечки. Проверять
	// надо запись без служебного поля, которое значений нести не может.
	delete(rec, "time")
	clean, err := json.Marshal(rec)
	if err != nil {
		t.Fatalf("запись лога не сериализуется: %v", err)
	}
	for _, secret := range []string{"5.1", "До еды"} {
		if strings.Contains(string(clean), secret) {
			t.Errorf("в логе оказалось значение точки %q:\n%s", secret, clean)
		}
	}
}

// Имена непокрытых секций в логе нужны — по ним видно, что поток принёс новое.
// Содержимого секций там быть не может: это данные о здоровье. И уровень от
// самой частичности не растёт — `partial` установившееся состояние половины
// потока, а постоянный WARN каждые пять минут обесценивает уровень.
func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))

	dir := t.TempDir()
	arch, err := archive.New(filepath.Join(dir, "raw"))
	if err != nil {
		t.Fatalf("архив: %v", err)
	}
	st, err := store.Open(filepath.Join(dir, "healthlog.db"))
	if err != nil {
		t.Fatalf("база: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })

	f := fold.New(arch, st, 0, log)
	ctx := context.Background()

	// Содержимое непокрытой секции помечено так, чтобы его нельзя было спутать
	// ни с чем: если оно окажется в логе, тест это увидит.
	const body = `{"data":{
		"metrics":[{"name":"step_count","units":"count","data":[
			{"date":"2025-06-05 10:00:00 +0300","qty":1},
			{"date":"2025-06-05 10:01:00 +0300","qty":2},
			{"date":"2025-06-05 10:02:00 +0300","qty":3},
			{"date":"2025-06-05 10:03:00 +0300","qty":4},
			{"date":"2025-06-05 10:04:00 +0300","qty":5},
			{"date":"2025-06-05 10:05:00 +0300","qty":6},
			{"date":"2025-06-05 10:06:00 +0300","qty":7},
			{"date":"2025-06-05 10:07:00 +0300","qty":8},
			{"date":"2025-06-05 10:08:00 +0300","qty":9},
			{"date":"2025-06-05 10:09:00 +0300","qty":10}]}],
		"ecg":[{"classification":"СЕКРЕТНЫЙ-РИТМ"}]}}`

	deliver(t, arch, st, "d1", "Minutes", "a1", []byte(body))
	if _, err := f.Fold(ctx, "d1"); err != nil {
		t.Fatalf("свёртка: %v", err)
	}

	// Смотрим на ВТОРУЮ доставку с той же секцией. Первая встреча имени —
	// событие само по себе и уровень поднимает законно; здесь же проверяется
	// другое: что уровень не поднимает сама по себе частичность, установившееся
	// состояние половины потока.
	deliver(t, arch, st, "d2", "Minutes", "a1", []byte(body))
	if _, err := f.Fold(ctx, "d2"); err != nil {
		t.Fatalf("повторная свёртка: %v", err)
	}

	out := buf.String()
	if !strings.Contains(out, "ecg") {
		t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
	}
	if strings.Contains(out, "СЕКРЕТНЫЙ-РИТМ") {
		t.Error("содержимое непокрытой секции утекло в лог")
	}

	var rec struct {
		Level     string   `json:"level"`
		Uncovered []string `json:"uncovered"`
	}
	line := strings.TrimSpace(out)
	if i := strings.LastIndex(line, "\n"); i >= 0 {
		line = line[i+1:]
	}
	if err := json.Unmarshal([]byte(line), &rec); err != nil {
		t.Fatalf("запись лога не разбирается: %v", err)
	}
	if rec.Level != "INFO" {
		t.Errorf("уровень %q, ожидался INFO: частичность — не отклонение", rec.Level)
	}
	if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "ecg" {
		t.Errorf("атрибут uncovered = %v, ожидался структурный список из ecg", rec.Uncovered)
	}
}

// Содержимое сущности чувствительнее значения точки: маршрут тренировки — это
// геотрек до дома, а метки состояния разума — измерение душевного состояния.
// Разрешены только координаты: род, идентификатор, интервал.
func TestFoldНеПишетСодержимогоСущностейВЛог(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))

	dir := t.TempDir()
	arch, err := archive.New(filepath.Join(dir, "raw"))
	if err != nil {
		t.Fatalf("архив: %v", err)
	}
	st, err := store.Open(filepath.Join(dir, "healthlog.db"))
	if err != nil {
		t.Fatalf("база: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })

	f := fold.New(arch, st, 0, log)
	ctx := context.Background()

	const full = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` +
		`"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` +
		`"route":[{"latitude":55.987654,"longitude":37.123456},{"latitude":55.987655,"longitude":37.123457}],` +
		`"totalEnergy":{"qty":404040.4}}],` +
		`"stateOfMind":[{"id":"e-открытый","start":"2025-06-05T18:00:00Z",` +
		`"labels":["СЕКРЕТНАЯ-ЭМОЦИЯ"],"valence":0.777777}]}}`

	// Вторая доставка теряет маршрут и меняет значения — та самая ветка, где
	// пишется WARN об удержанной версии и где велик соблазн приписать «что
	// именно потерялось».
	const poorer = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` +
		`"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` +
		`"totalEnergy":{"qty":505050.5}}]}}`

	deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(full))
	if _, err := f.Fold(ctx, "d1"); err != nil {
		t.Fatalf("свёртка первой доставки: %v", err)
	}
	deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(poorer))
	if _, err := f.Fold(ctx, "d2"); err != nil {
		t.Fatalf("свёртка второй доставки: %v", err)
	}

	logged := buf.String()
	for _, secret := range []string{"55.98", "37.12", "latitude", "СЕКРЕТНАЯ-ЭМОЦИЯ", "404040", "505050", "0.777777"} {
		if strings.Contains(logged, secret) {
			t.Errorf("в логе оказалось %q:\n%s", secret, logged)
		}
	}
	// Координаты, наоборот, обязаны быть: без них счётчик удержанных версий не
	// говорит, какая сущность пострадала.
	if !strings.Contains(logged, "w-открытый") {
		t.Error("координат удержанной сущности в логе нет")
	}
	if !strings.Contains(logged, "poorer entity version held") {
		t.Error("удержание обеднённой версии не отмечено записью WARN")
	}
}

// Счётчики разбора доезжают до лога ручным присваиванием, и забытая строка
// молча выключила бы наблюдение — тот самый класс, ради которого счётчики
// слияния встроены структурой. Тест закрепляет имена атрибутов.
func TestFoldСчётчикиСущностейДоезжаютДоЛога(t *testing.T) {
	t.Parallel()

	var buf bytes.Buffer
	log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))

	dir := t.TempDir()
	arch, err := archive.New(filepath.Join(dir, "raw"))
	if err != nil {
		t.Fatalf("архив: %v", err)
	}
	st, err := store.Open(filepath.Join(dir, "healthlog.db"))
	if err != nil {
		t.Fatalf("база: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })

	f := fold.New(arch, st, 0, log)

	// Одна годная тренировка, одна без `id`, одна с неразбираемой меткой и один
	// элемент, не являющийся объектом: три класса пропуска плюс успех.
	const body = `{"data":{"workouts":[
		{"id":"w1","start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300"},
		{"start":"2025-06-05 11:00:00 +0300"},
		{"id":"w3","start":"позавчера"},
		"строка вместо объекта"]}}`

	deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(body))
	if _, err := f.Fold(context.Background(), "d1"); err != nil {
		t.Fatalf("свёртка: %v", err)
	}

	var rec struct {
		Workouts        int `json:"workouts"`
		WorkoutsWritten int `json:"workouts_written"`
		Records         int `json:"records"`
		RecordsWritten  int `json:"records_written"`
		EntitiesHeld    int `json:"entities_held"`
		SkippedEntities int `json:"skipped_entities"`
	}
	line := strings.TrimSpace(buf.String())
	if i := strings.LastIndex(line, "\n"); i >= 0 {
		line = line[i+1:]
	}
	if err := json.Unmarshal([]byte(line), &rec); err != nil {
		t.Fatalf("запись лога не разбирается: %v", err)
	}

	if rec.Workouts != 1 || rec.WorkoutsWritten != 1 {
		t.Errorf("тренировок %d, записано %d — ожидалось 1 и 1", rec.Workouts, rec.WorkoutsWritten)
	}
	if rec.SkippedEntities != 3 {
		t.Errorf("пропущено сущностей %d, ожидалось 3 — счётчик не доехал до лога", rec.SkippedEntities)
	}
	if rec.Records != 0 || rec.RecordsWritten != 0 || rec.EntitiesHeld != 0 {
		t.Errorf("лишние счётчики: записей %d/%d, удержано %d",
			rec.Records, rec.RecordsWritten, rec.EntitiesHeld)
	}
}

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

	var buf bytes.Buffer
	log := slog.New(slog.NewJSONHandler(&buf, nil))

	dir := t.TempDir()
	arch, err := archive.New(filepath.Join(dir, "raw"))
	if err != nil {
		t.Fatalf("архив: %v", err)
	}
	st, err := store.Open(filepath.Join(dir, "healthlog.db"))
	if err != nil {
		t.Fatalf("база: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })

	f := fold.New(arch, st, 0, log)
	ctx := context.Background()

	const body = `{"data":{"metrics":[{"name":"blood_glucose","units":"mg/dL","data":[` +
		`{"date":"2025-06-05 10:00:00 +0300",%s}` +
		`]}]}}`

	deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":5.1,"mealTime":"До еды"`)))
	if _, err := f.Fold(ctx, "d1"); err != nil {
		t.Fatalf("первая свёртка: %v", err)
	}
	buf.Reset()

	// Беднее по именам И с другим значением общего ключа: Relate объявит пару
	// равнополной, и защитить `mealTime` будет некому.
	deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":6.2`)))
	stats, err := f.Fold(ctx, "d2")
	if err != nil {
		t.Fatalf("вторая свёртка: %v", err)
	}
	if stats.PointsErased != 1 {
		t.Fatalf("потерь содержания %d, ожидалась 1: тест проверяет не то", stats.PointsErased)
	}
	if stats.Incomparable != 0 {
		t.Fatalf("несравнимых %d, ожидалось 0: случай перепутан с несравнимостью", stats.Incomparable)
	}

	var rec map[string]any
	if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &rec); err != nil {
		t.Fatalf("строка лога не JSON: %v\n%s", err, buf.String())
	}
	if rec["level"] != "WARN" {
		t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String())
	}
	if rec["msg"] != "delivery folded, arriving point dropped stored field" {
		t.Errorf("сообщение %v не называет событие", rec["msg"])
	}
	if rec["points_erased"] != float64(1) {
		t.Errorf("счётчик потерь %v, ожидался 1", rec["points_erased"])
	}
	at, _ := rec["points_erased_at"].(string)
	if !strings.Contains(at, "blood_glucose/minute@") {
		t.Errorf("координаты объекта в записи не те: %q", at)
	}
	// Ни значения точки, ни потерянной строки: `mealTime` — контекст измерения,
	// то есть данные о здоровье. Проверяется по разобранной записи, а не по
	// сырому буферу.
	delete(rec, "time")
	for k, v := range rec {
		if s, ok := v.(string); ok && strings.Contains(s, "До еды") {
			t.Errorf("в поле %q оказалось значение точки: %q", k, s)
		}
	}
}
