package fold_test

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

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

// Тело с обеими наблюдаемыми формами: фаза сна, которую словарь знает, и
// контекст пульса, которого он не знает.
const categoricalBody = `{"data":{"metrics":[` +
	`{"name":"sleep_analysis","units":"hr","data":[` +
	`{"date":"2025-06-05 10:00:00 +0300","start":"2025-06-05 10:00:00 +0300",` +
	`"end":"2025-06-05 11:00:00 +0300","qty":1,"value":"Во сне"}]},` +
	`{"name":"heart_rate","units":"count/min","data":[` +
	`{"date":"2025-06-05 10:00:00 +0300","Avg":62,"context":"Сидячий образ жизни"}]}` +
	`]}}`

// receivedAt — фиксированная метка приёма.
//
// Не `store.Now()`: провенанс первой встречи входит в отпечаток витрины, и
// метка от часов сделала бы сравнение отпечатков между подтестами функцией
// того, в одну ли секунду они успели отработать. Ровно так этот тест и покраснел
// на гейте — у трёх подтестов из четырёх метки совпали, у четвёртого нет.
var receivedAt = time.Date(2025, 6, 5, 7, 0, 0, 0, time.UTC)

func deliverWithHeaders(t *testing.T, arch *archive.Archive, st *store.Store, id, headers string, body []byte) {
	t.Helper()

	at := receivedAt
	rawPath, err := arch.Write(id, at, body)
	if err != nil {
		t.Fatalf("запись в архив: %v", err)
	}
	err = st.CreateDelivery(context.Background(), store.Delivery{
		ID:           id,
		ReceivedAt:   at,
		AutomationID: "auto-1",
		Aggregation:  "Hours",
		Bytes:        int64(len(body)),
		SHA256:       "-",
		RawPath:      rawPath,
		Headers:      headers,
		ParseStatus:  store.ParsePending,
	})
	if err != nil {
		t.Fatalf("запись доставки: %v", err)
	}
}

func foldService(t *testing.T, log *slog.Logger) (*fold.Service, *archive.Archive, *store.Store) {
	t.Helper()

	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() })
	return fold.New(arch, st, 0, log), arch, st
}

func TestFoldКладётНаблюденияВРеестр(t *testing.T) {
	t.Parallel()

	f, arch, st := foldService(t, slog.New(slog.DiscardHandler))
	deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(categoricalBody))

	stats, err := f.Fold(context.Background(), "d1")
	if err != nil {
		t.Fatalf("свёртка: %v", err)
	}
	if stats.Categoricals != 2 {
		t.Errorf("наблюдений %d, ожидалось 2", stats.Categoricals)
	}
	if stats.CategoricalUnknown != 1 {
		t.Errorf("строк без кода %d, ожидалась 1 (контекст пульса)", stats.CategoricalUnknown)
	}

	got, err := st.CategoryValues(context.Background())
	if err != nil {
		t.Fatalf("чтение реестра: %v", err)
	}
	if len(got) != 2 {
		t.Fatalf("строк реестра %d: %+v", len(got), got)
	}
	if got[1].Value != "Во сне" || got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" {
		t.Errorf("фаза сна в реестре: %+v", got[1])
	}
	if got[0].Value != "Сидячий образ жизни" || got[0].Code != "" {
		t.Errorf("контекст пульса в реестре: %+v", got[0])
	}
	if got[1].FirstDeliveryID != "d1" {
		t.Errorf("провенанс %q, ожидался d1", got[1].FirstDeliveryID)
	}
}

// Локаль сужает поиск по словарю, но в ключ не входит и вывода не отменяет:
// заголовков в сыром архиве нет, и доставка, восстановленная из осиротевшего
// тела, обязана дать то же состояние.
func TestFoldЛокальНеМеняетСостояния(t *testing.T) {
	t.Parallel()

	cases := []struct{ name, headers string }{
		{"измеренный заголовок потока", `{"Accept-Language":["ru"]}`},
		{"подтег, веса и регистр", `{"accept-language":["RU-ru,ru;q=0.9,en;q=0.8"]}`},
		{"заголовка нет вовсе", `{}`},
		{"незнакомая локаль", `{"Accept-Language":["de"]}`},
		// Пустая строка и битый JSON — не вычурность: заголовков в сыром архиве
		// нет вовсе, и доставка, восстановленная из осиротевшего тела, приезжает
		// ровно так. Оба обязаны дать пустую локаль и то же состояние.
		{"заголовков нет вовсе", ``},
		{"заголовки не разбираются", `не json`},
		{"заголовок пустым списком", `{"Accept-Language":[]}`},
	}

	var want string
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			f, arch, st := foldService(t, slog.New(slog.DiscardHandler))
			deliverWithHeaders(t, arch, st, "d1", c.headers, []byte(categoricalBody))
			if _, err := f.Fold(context.Background(), "d1"); err != nil {
				t.Fatalf("свёртка: %v", err)
			}
			got, err := st.CategoryValues(context.Background())
			if err != nil {
				t.Fatalf("чтение реестра: %v", err)
			}
			if len(got) != 2 {
				t.Fatalf("строк реестра %d: %+v", len(got), got)
			}
			if got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" {
				t.Errorf("код фазы сна %q — локаль отменила вывод", got[1].Code)
			}
			// Отпечаток не должен зависеть от заголовка вовсе: ключ реестра —
			// функция одних тел.
			fp, err := st.Fingerprint(context.Background())
			if err != nil {
				t.Fatalf("отпечаток: %v", err)
			}
			if want == "" {
				want = fp
			} else if fp != want {
				t.Errorf("заголовок %s сдвинул отпечаток витрины", c.headers)
			}
		})
	}
}

// Строки категориальных значений — данные о здоровье наравне со значением
// точки: «Сидячий образ жизни» описывает человека. В журнал уходят только
// счётчики.
//
// Запись РАЗБИРАЕТСЯ, служебное `time` выбрасывается, и поиск идёт в остатке:
// метка времени содержит произвольные цифры, и поиск по сырому буферу делает
// такой тест флаки по построению (запись 2026-08-02 в docs/review.md).
func TestFoldНеПишетКатегориальныхСтрокВЛог(t *testing.T) {
	t.Parallel()

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

	f, arch, st := foldService(t, log)
	deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(categoricalBody))
	if _, err := f.Fold(context.Background(), "d1"); err != nil {
		t.Fatalf("свёртка: %v", err)
	}

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

	var folded map[string]any
	for line := range strings.SplitSeq(logged, "\n") {
		var rec map[string]any
		if err := json.Unmarshal([]byte(line), &rec); err != nil {
			t.Fatalf("строка лога не JSON: %v", err)
		}
		delete(rec, "time")
		clean, err := json.Marshal(rec)
		if err != nil {
			t.Fatalf("запись лога не сериализуется: %v", err)
		}
		for _, secret := range []string{"Во сне", "Сидячий образ жизни", "HKCategoryValue"} {
			if strings.Contains(string(clean), secret) {
				t.Errorf("в логе оказалось %q:\n%s", secret, clean)
			}
		}
		if msg, _ := rec["msg"].(string); strings.HasPrefix(msg, "delivery folded") {
			folded = rec
		}
	}

	if folded == nil {
		t.Fatalf("записи об исходе свёртки нет:\n%s", logged)
	}
	for _, attr := range []string{"categoricals", "categoricals_unknown", "categoricals_dropped"} {
		if _, ok := folded[attr]; !ok {
			t.Errorf("в записи нет счётчика %q: %v", attr, folded)
		}
	}
	if folded["categoricals_unknown"] != float64(1) {
		t.Errorf("счётчик строк без кода %v, ожидалась 1", folded["categoricals_unknown"])
	}
}

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

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

	f, arch, st := foldService(t, log)

	// Одно непомерно длинное значение — граница длины, а не числа: тело
	// остаётся маленьким, а событие то же.
	long := strings.Repeat("я", 200)
	body := `{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[` +
		`{"date":"2025-06-05 10:00:00 +0300","start":"2025-06-05 10:00:00 +0300",` +
		`"end":"2025-06-05 11:00:00 +0300","qty":1,"value":"` + long + `"}]}]}}`
	deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(body))

	stats, err := f.Fold(context.Background(), "d1")
	if err != nil {
		t.Fatalf("свёртка: %v", err)
	}
	if stats.CategoricalDropped == 0 {
		t.Fatal("граница не сработала — тест проверяет не то")
	}

	var rec map[string]any
	for line := range strings.SplitSeq(strings.TrimSpace(buf.String()), "\n") {
		var r map[string]any
		if err := json.Unmarshal([]byte(line), &r); err != nil {
			t.Fatalf("строка лога не JSON: %v", err)
		}
		if msg, _ := r["msg"].(string); strings.HasPrefix(msg, "delivery folded") {
			rec = r
		}
	}
	if rec == nil {
		t.Fatalf("записи об исходе свёртки нет:\n%s", buf.String())
	}
	if rec["level"] != "WARN" {
		t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String())
	}
	if rec["msg"] != "delivery folded, categorical values dropped by limit" {
		t.Errorf("сообщение %v не называет событие", rec["msg"])
	}

	// И ни одного байта самой строки: она из тела доставки.
	delete(rec, "time")
	clean, err := json.Marshal(rec)
	if err != nil {
		t.Fatalf("запись лога не сериализуется: %v", err)
	}
	if strings.Contains(string(clean), "яяя") {
		t.Errorf("в логе оказалось значение из тела:\n%s", clean)
	}
}
