package hae_test

import (
	"encoding/json"
	"strings"
	"testing"

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

func parseFixture(t *testing.T, name string) hae.Result {
	t.Helper()

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

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

	res := parseFixture(t, "workout_route.json")

	var w hae.Entity
	for _, e := range res.Workouts {
		if strings.Contains(string(e.Raw), `"route"`) {
			w = e
		}
	}
	if w.ID == "" {
		t.Fatal("уличной тренировки с маршрутом в фикстуре не нашлось")
	}
	if w.Name == "" {
		t.Error("имя пусто — по нему идёт выборка заголовков")
	}
	if !w.End.After(w.Start) {
		t.Errorf("интервал %v — %v", w.Start, w.End)
	}
	if w.OffsetSeconds != 3*3600 {
		t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds)
	}
	if w.Duration == nil || *w.Duration <= 0 {
		t.Errorf("длительность %v — обязана браться из тела", w.Duration)
	}

	var body map[string]json.RawMessage
	if err := json.Unmarshal(w.Raw, &body); err != nil {
		t.Fatalf("содержимое не разбирается: %v", err)
	}
	for _, key := range []string{"route", "heartRateData", "activeEnergy", "heartRateRecovery"} {
		if _, ok := body[key]; !ok {
			t.Errorf("в содержимом нет %q — внутренние ряды обязаны храниться дословно", key)
		}
	}
	// Точки маршрута проходят исходными байтами: их метка (`timestamp`)
	// меткой сущности не является и не разбирается.
	if !strings.Contains(string(body["route"]), "timestamp") {
		t.Error("точки маршрута потеряли своё поле времени")
	}
}

// Пульс приезжает дважды — в общем потоке метрик и внутри тренировки. Это
// разные таблицы; смешение задвоило бы ряд.
func TestParseРядПульсаТренировкиНеСтановитсяМетрикой(t *testing.T) {
	t.Parallel()

	res := parseFixture(t, "workout_route.json")

	if len(res.Points) != 0 {
		t.Errorf("точек %d, ожидалось 0: доставка несёт только тренировки", len(res.Points))
	}
	if res.Metrics != 0 {
		t.Errorf("метрик %d, ожидалось 0", res.Metrics)
	}
	if len(res.Uncovered) != 0 {
		t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
	}
}

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

	res := parseFixture(t, "workout_indoor.json")

	if len(res.Workouts) == 0 {
		t.Fatal("тренировок нет")
	}
	var body map[string]json.RawMessage
	if err := json.Unmarshal(res.Workouts[0].Raw, &body); err != nil {
		t.Fatalf("содержимое не разбирается: %v", err)
	}
	if _, ok := body["route"]; ok {
		t.Error("у домашней тренировки взялся маршрут")
	}
	for _, key := range []string{"temperature", "humidity", "intensity"} {
		if _, ok := body[key]; !ok {
			t.Errorf("в содержимом нет %q", key)
		}
	}
}

// stateOfMind живёт по другим соглашениям: RFC 3339 в UTC, коды HealthKit
// вместо переводов, поля source нет вовсе.
func TestParseСостояниеРазума(t *testing.T) {
	t.Parallel()

	res := parseFixture(t, "state_of_mind.json")

	if len(res.Records) == 0 {
		t.Fatal("записей нет")
	}
	for _, r := range res.Records {
		if r.Kind != "stateOfMind" {
			t.Errorf("род %q, ожидался stateOfMind — имя секции хранится дословно", r.Kind)
		}
		if r.ID == "" {
			t.Error("идентификатор пуст")
		}
		// HAE прислал UTC — офсет ноль. Это значит «источник прислал UTC», а не
		// «человек был в Гринвиче»: местной зоны у секции в потоке нет вовсе.
		if r.OffsetSeconds != 0 {
			t.Errorf("офсет %d, ожидался 0", r.OffsetSeconds)
		}
		if r.Start.IsZero() {
			t.Error("метка не разобралась — RFC 3339 обязан приниматься")
		}
	}
	if len(res.Uncovered) != 0 {
		t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
	}
}

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

	res := parseFixture(t, "handmade_entities.json")

	byID := make(map[string]hae.Entity, len(res.Workouts))
	for _, w := range res.Workouts {
		byID[w.ID] = w
	}

	// Пустой id, отсутствующий id, id длиннее предела и id не строкой — один
	// счётчик на четыре случая: исход у них общий, сущность не адресуема.
	if res.SkippedNoID != 4 {
		t.Errorf("пропущено по идентификатору %d, ожидалось 4", res.SkippedNoID)
	}
	// Метка не разбирается, метки нет вовсе и начало приехало не строкой.
	if res.SkippedEntityNoTime != 3 {
		t.Errorf("пропущено по метке %d, ожидалось 3", res.SkippedEntityNoTime)
	}
	// Элемент, не являющийся объектом.
	if res.SkippedEntityMalformed != 1 {
		t.Errorf("пропущено по форме %d, ожидалось 1", res.SkippedEntityMalformed)
	}

	t.Run("нечитаемый конец не отбрасывает тренировку", func(t *testing.T) {
		w, ok := byID["00000000-0000-4000-8000-000000000003"]
		if !ok {
			t.Fatal("тренировка с нечитаемым концом потерялась целиком")
		}
		if !w.End.Equal(w.Start) {
			t.Errorf("конец %v, ожидался равным началу %v", w.End, w.Start)
		}
		if !strings.Contains(string(w.Raw), "никогда") {
			t.Error("исходное значение конца не сохранилось дословно")
		}
	})

	// Значение не того ТИПА стоит одного поля, а не сущности: иначе `name`,
	// приехавшее числом, уносит тренировку вместе с маршрутом, а доставка при
	// этом числится разобранной.
	t.Run("имя числом не уносит тренировку", func(t *testing.T) {
		w, ok := byID["00000000-0000-4000-8000-00000000000c"]
		if !ok {
			t.Fatal("тренировка с именем-числом потерялась целиком")
		}
		if w.Name != "" {
			t.Errorf("имя %q, ожидалось пустое", w.Name)
		}
		if !strings.Contains(string(w.Raw), `"lat"`) {
			t.Error("маршрут не сохранился дословно")
		}
	})

	t.Run("конец числом не уносит тренировку", func(t *testing.T) {
		w, ok := byID["00000000-0000-4000-8000-00000000000d"]
		if !ok {
			t.Fatal("тренировка с концом-числом потерялась целиком")
		}
		if !w.End.Equal(w.Start) {
			t.Errorf("конец %v, ожидался равным началу %v", w.End, w.Start)
		}
	})

	// Фолбэк `start → date` существует для сущностей, у которых `start` не
	// прислан ВОВСЕ. Непонятое значение `start` фолбэка не получает: подстановка
	// другого поля дала бы метку другого момента времени, неотличимую от
	// настоящей и ничем не считаемую.
	t.Run("начало числом не подменяется полем date", func(t *testing.T) {
		if _, ok := byID["00000000-0000-4000-8000-00000000000e"]; ok {
			t.Error("нестроковое начало молча заменено меткой из date")
		}
	})

	t.Run("идентификатор числом пропускает сущность", func(t *testing.T) {
		for id := range byID {
			if id == "42" {
				t.Error("нестроковый идентификатор приведён к строке — идентичность выдумана за источник")
			}
		}
	})

	t.Run("нечисловая длительность не становится нулём", func(t *testing.T) {
		w := byID["00000000-0000-4000-8000-000000000004"]
		if w.Duration != nil {
			t.Errorf("длительность %v, ожидалось отсутствие", *w.Duration)
		}
	})

	t.Run("отсутствующая длительность отличима от нуля", func(t *testing.T) {
		w := byID["00000000-0000-4000-8000-000000000005"]
		if w.Duration != nil {
			t.Errorf("длительность %v, ожидалось отсутствие", *w.Duration)
		}
	})

	t.Run("начало берётся из date, когда start отсутствует", func(t *testing.T) {
		w, ok := byID["00000000-0000-4000-8000-000000000006"]
		if !ok {
			t.Fatal("тренировка с меткой в date потерялась")
		}
		if w.Start.IsZero() {
			t.Error("метка не разобралась")
		}
	})

	t.Run("незнакомое поле переживает разбор дословно", func(t *testing.T) {
		w := byID["00000000-0000-4000-8000-000000000009"]
		for _, lit := range []string{"невиданноеПоле", "1.0", "9007199254740993", "0.123456789012345678"} {
			if !strings.Contains(string(w.Raw), lit) {
				t.Errorf("литерал %q потерян при разборе", lit)
			}
		}
	})

	t.Run("запись со временем в формате метрик тоже разбирается", func(t *testing.T) {
		var daily *hae.Entity
		for i, r := range res.Records {
			if strings.Contains(string(r.Raw), "daily_mood") {
				daily = &res.Records[i]
			}
		}
		if daily == nil {
			t.Fatal("запись daily_mood потерялась")
		}
		if daily.OffsetSeconds != 3*3600 {
			t.Errorf("офсет %d, ожидался 10800: формат метрик обязан приниматься", daily.OffsetSeconds)
		}
	})
}

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

	const body = `{"data":{"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}],"metrics":`
	res, err := hae.Parse([]byte(body), hae.Meta{})
	if err == nil {
		t.Fatal("оборванное тело разобралось без ошибки")
	}
	if len(res.Workouts) != 0 {
		t.Errorf("сущностей %d, ожидалось 0", len(res.Workouts))
	}
}

// Невыводимый слой — тоже «всё или ничего»: доставка целиком уходит в failed,
// иначе она получила бы failed при частично записанной витрине.
func TestParseНевыводимыйСлойНеОтдаётСущностей(t *testing.T) {
	t.Parallel()

	const body = `{"data":{
		"metrics":[{"name":"vo2_max","units":"ml/kg*min","data":[{"date":"2025-06-05 10:11:12 +0300","qty":1}]}],
		"stateOfMind":[{"id":"e1","start":"2025-06-05T18:00:00Z","valence":0.5}]}}`

	res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Default"})
	if err == nil {
		t.Fatal("доставка без выводимого слоя разобралась без ошибки")
	}
	if len(res.Records) != 0 {
		t.Errorf("записей %d, ожидалось 0", len(res.Records))
	}
	if len(res.Points) != 0 {
		t.Errorf("точек %d, ожидалось 0", len(res.Points))
	}
}

// Повтор ключа покрытой секции объединяет её, а не отдаёт победу последней.
func TestParseПовторСекцииТренировокОбъединяет(t *testing.T) {
	t.Parallel()

	const body = `{"data":{
		"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}],
		"workouts":[{"id":"w2","start":"2025-06-05 11:00:00 +0300"}]}}`

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

// Повтор самого члена `data` накапливает результаты: присваивание теряло бы
// секции первого члена молча — их имена уже отмечены и во второй список не
// попали бы.
func TestParseПовторЧленаDataНакапливает(t *testing.T) {
	t.Parallel()

	const body = `{"data":{"ecg":[{"id":"e"}]},"data":{"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}]}}`

	res, err := hae.Parse([]byte(body), hae.Meta{})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}
	if len(res.Workouts) != 1 {
		t.Errorf("тренировок %d, ожидалась 1", len(res.Workouts))
	}
	if len(res.Uncovered) != 1 || res.Uncovered[0] != "ecg" {
		t.Errorf("непокрытые %v, ожидался [ecg] — иначе секция потеряна молча", res.Uncovered)
	}
}

// Непокрытой секцией с собственными `id` остаются те, чьей формы никто не
// видел: разбор вслепую хуже честного «не покрыто».
func TestParseНепокрытыеСекцииССобственнымиID(t *testing.T) {
	t.Parallel()

	const body = `{"data":{"ecg":[{"id":"e1","start":"2025-06-05 10:00:00 +0300"}]}}`

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

// Элемент, который сам не объект, обязан идти в СВОЙ счётчик: сменившаяся форма
// секции и сменившаяся форма идентификатора лечатся по-разному. `null` при этом
// самый коварный — `json.Unmarshal` считает его законным no-op и не ошибается.
func TestParseЭлементНеОбъектИдётВСвойСчётчик(t *testing.T) {
	t.Parallel()

	res, err := hae.Parse([]byte(`{"data":{"workouts":[null,"строка",42,[1,2]]}}`), hae.Meta{})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}
	if res.SkippedEntityMalformed != 4 {
		t.Errorf("не разобралось как объект %d, ожидалось 4", res.SkippedEntityMalformed)
	}
	if res.SkippedNoID != 0 {
		t.Errorf("пропущено по идентификатору %d, ожидалось 0: форма секции — не форма id",
			res.SkippedNoID)
	}
}

// Повтор ключа JSON допускает, и весь разбор проекта пользуется семантикой
// «побеждает последнее». Мягкое чтение обязано ей следовать: признак, копящийся
// между вызовами, сделал бы заголовок функцией истории вызовов, а не тела.
func TestParseПовторКлючаМеткиРешаетсяПоследнимЗначением(t *testing.T) {
	t.Parallel()

	body := `{"data":{"workouts":[{"id":"w1","start":123,"start":"2025-06-05 07:00:00 +0300"}]}}`
	res, err := hae.Parse([]byte(body), hae.Meta{})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}
	if len(res.Workouts) != 1 {
		t.Fatalf("тренировок %d, ожидалась 1: валидная метка стоит последней", len(res.Workouts))
	}
	if res.Workouts[0].Start.IsZero() {
		t.Error("метка не разобралась")
	}
}

// `"start": null` — это ключ, который пришёл. Фолбэк на `date` для него не
// срабатывает: подстановка дала бы метку ДРУГОГО момента времени, неотличимую
// от настоящей и ничем не считаемую.
func TestParseПустойStartНеПодменяетсяПолемDate(t *testing.T) {
	t.Parallel()

	body := `{"data":{"workouts":[{"id":"w1","date":"2025-06-01 00:00:00 +0300",` +
		`"start":null,"end":"2025-06-05 07:10:00 +0300"}]}}`
	res, err := hae.Parse([]byte(body), hae.Meta{})
	if err != nil {
		t.Fatalf("разбор: %v", err)
	}
	if len(res.Workouts) != 0 {
		t.Errorf("тренировка получила метку %v из чужого поля", res.Workouts[0].Start)
	}
	if res.SkippedEntityNoTime != 1 {
		t.Errorf("пропущено по метке %d, ожидалось 1", res.SkippedEntityNoTime)
	}
}
