package replay_test

import (
	"bytes"
	"compress/gzip"
	"context"
	"flag"
	"io"
	"log/slog"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"testing"
	"time"

	"git.vakhrushev.me/av/healthlog/internal/catalog"
	"git.vakhrushev.me/av/healthlog/internal/hae"
	"git.vakhrushev.me/av/healthlog/internal/healthkit"
	"git.vakhrushev.me/av/healthlog/internal/ident"
	"git.vakhrushev.me/av/healthlog/internal/replay"
	"git.vakhrushev.me/av/healthlog/internal/store"
)

// archiveDir включает прогон сходимости на живом архиве.
//
// Флагом, а не переменной окружения и не путём по умолчанию: архив в
// репозиторий не попадает (данные о здоровье), прогон занимает минуту и не
// должен висеть на каждом `task gate`. Запускается командой
// `task verify:archive`.
var archiveDir = flag.String("healthlog.archive", "",
	"каталог сырого архива для прогона сходимости (по умолчанию прогон пропускается)")

// Сходимость на живом архиве: тот же корпус, на котором выводились правила
// разбора, обязан пройти через код без потерь и без расхождений — и повторное
// проигрывание журнала обязано дать то же состояние.
//
// Прогон идёт через ту же операцию, которой пересобирает витрину
// `healthlog reindex`. Собственный обход и собственный порядок здесь были
// раньше и были ошибкой: они образовывали второй проигрыватель журнала, чьи
// правила разошлись с настоящим, — а зеленел бы при этом он.
func TestReplayЖивогоАрхива(t *testing.T) {
	if *archiveDir == "" {
		t.Skip("прогон живого архива выключен: задайте -healthlog.archive")
	}
	bodies := collectBodies(t, *archiveDir)
	if len(bodies) == 0 {
		t.Skipf("живого архива нет в %s — прогон пропущен", *archiveDir)
	}

	dir := t.TempDir()
	arch := openArchive(t, filepath.Join(dir, "raw"))
	src := openStore(t, filepath.Join(dir, "live.db"))
	ctx := context.Background()

	// Тела в архиве сжаты; распаковываем и кладём через тот же архив, чтобы
	// путь чтения был ровно тот, каким пойдёт пересборка.
	//
	// Заголовки доставки в архиве не лежат — они были заголовками запроса.
	// Поэтому автоматизация у всех одна: так проверяется в том числе
	// наследование слоя по цепочке доставок. Метка приёма берётся из ULID,
	// то есть хронология журнала настоящая.
	for _, path := range bodies {
		body, err := os.ReadFile(path)
		if err != nil {
			t.Fatalf("чтение %s: %v", path, err)
		}
		id := strings.TrimSuffix(filepath.Base(path), ".json.gz")
		at, err := ident.TimeOf(id)
		if err != nil {
			t.Fatalf("время из ULID %s: %v", id, err)
		}
		writeBody(t, arch, src, item{id: id, at: at, automationID: "auto"}, gunzip(t, body))
	}

	first, dst := run(t, ctx, arch, src, filepath.Join(dir, "first.db"))

	// Несравнимые наборы полей — посылка, на которой стоит отказ от объединения
	// полей: их не было ни разу на всём корпусе. Число печатается, а не
	// проверяется: появление такого набора — событие для разбора, а не отказ
	// сходимости.
	t.Logf("тел %d: свёрнуто %d; отказов: слой %d, содержимое %d, прочее %d; несравнимых наборов %d",
		first.Bodies, first.Folded, first.FailedLayer, first.FailedMalformed,
		first.FailedOther, first.Incomparable)
	// Удержания печатаются, а не утверждаются: число производно от размера
	// корпуса, а прогон живого архива в гейт не входит — константа покраснела бы
	// молча (docs/review.md, 2026-08-02). Утверждается инвариант: удержание
	// возможно только там, где сохранённая точка строго полнее, то есть их не
	// может быть больше, чем столкновений вообще.
	t.Logf("удержано точек правилом полноты %d, содержание унесено пришедшей %d",
		first.PointsHeld, first.PointsErased)
	// Посылка равенства «пересборка = приём» называется рядом с ним: отказавшие
	// доставки живой путь в витрину не записал, а пересборка запишет, и
	// расхождение отпечатков было бы отказом не правила, а посылки.
	t.Logf("посылка сходимости: отказавших доставок %d",
		first.FailedLayer+first.FailedMalformed+first.FailedOther)
	t.Logf("частично разобрано %d, подобрано без учёта %d, записей без тела %d",
		first.Partial, first.Adopted, first.Orphans)

	if first.Folded == 0 {
		t.Fatal("ни одна доставка не свернулась")
	}

	// Половина живого потока не несёт metrics вовсе (находка 50): это
	// тренировки и состояние разума. С тех пор как обе секции покрыты,
	// частично разобранных доставок в архиве нет — и вместо них проверяется то,
	// ради чего покрытие делалось: сущности доехали до витрины.
	//
	// Проверяется свойство, а не число: корпус растёт с каждой доставкой, а
	// прогон живого архива в гейт не входит, так что протухшая константа
	// покраснела бы молча.
	if first.Workouts == 0 {
		t.Error("тренировок в витрине нет — секция workouts не разбирается")
	}
	if first.Records == 0 {
		t.Error("записей в витрине нет — секция stateOfMind не разбирается")
	}
	// Удержанные версии сущностей печатаются рядом с их числом. Число — это
	// «сколько лежит», а удержания — «сколько правило слияния не пустило», и
	// второе отпечатком не проверяется по построению: живой приём и пересборка
	// пользуются одним правилом и одинаково сойдутся на одинаково удержанной
	// версии. Печатается, а не утверждается: удержание — событие для разбора,
	// а не отказ сходимости.
	t.Logf("тренировок %d, записей %d, удержано версий сущностей %d",
		first.Workouts, first.Records, first.EntitiesHeld)

	// Повторное проигрывание того же журнала даёт то же состояние: свёртка
	// детерминирована, и пересборка даёт то же, что живой приём.
	//
	// Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате
	// всегда лежит ровно одна точка, и правило разрешения столкновений выбирает,
	// какая это будет точка, а не сколько их. Счёт объектов совпал бы и при
	// заведомо сломанном правиле.
	second, _ := run(t, ctx, arch, src, filepath.Join(dir, "second.db"))
	if second.Buckets != first.Buckets {
		t.Errorf("повторное проигрывание изменило число объектов: %d → %d", first.Buckets, second.Buckets)
	}
	if second.Workouts != first.Workouts || second.Records != first.Records {
		t.Errorf("повторное проигрывание изменило число сущностей: %d/%d → %d/%d",
			first.Workouts, first.Records, second.Workouts, second.Records)
	}
	if second.Fingerprint != first.Fingerprint {
		t.Errorf("повторное проигрывание изменило содержимое объектов:\n  %s\n  %s",
			first.Fingerprint, second.Fingerprint)
	}

	// Отпечаток печатается всегда: это единственный способ сравнить состояние с
	// тем, что давала прежняя редакция правила слияния. Эталон в репозитории не
	// живёт — он производен от архива, которого нет ни на одной другой машине.
	// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
	t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint)

	measureStyles(t, dst)

	// Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним
	// `date` лежит до трёх записей (docs/research/apple-health.md, находка 47).
	//
	// Проверяется само свойство, а не измеренное когда-то число. Прежняя
	// редакция сравнивала с константой 174, снятой на 94 доставках, и покраснела
	// молча, когда архив дорос до 116: константа, производная от корпуса,
	// протухает с каждой новой доставкой, а прогон живого архива в гейт не
	// входит, так что краснота никому не видна.
	coords, labels := countSleepKeys(t, dst)
	if coords == 0 {
		t.Fatal("записей сна в витрине нет — проверять нечего")
	}
	if coords <= labels {
		t.Errorf("координат сна %d при %d различных метках: ключ схлопнул записи до метки",
			coords, labels)
	}
	t.Logf("координат сна %d, различных меток %d", coords, labels)

	measureCategories(t, dst, second)
}

// measureCategories проверяет реестр категориальных значений на живом корпусе.
//
// Здесь он проверяется единственным способом, каким это вообще возможно: против
// строк, которые телефон действительно присылал. Заголовков в архиве нет, то
// есть локаль у всех доставок пуста — прогон заодно доказывает, что вывод кода
// не зависит от уцелевшей учётной строки.
//
// Утверждаются СВОЙСТВА, а не числа: число строк реестра и число строк без кода
// растут вместе с потоком, а прогон живого архива в гейт не входит — константа
// покраснела бы молча (docs/review.md, 2026-08-02). Измеренное печатается.
func measureCategories(t *testing.T, dst *store.Store, second replay.Report) {
	t.Helper()

	values, err := dst.CategoryValues(context.Background())
	if err != nil {
		t.Fatalf("реестр категориальных значений: %v", err)
	}
	if len(values) == 0 {
		t.Fatal("реестр пуст — категориальные значения не извлекаются вовсе")
	}
	if int64(len(values)) != second.Categories {
		t.Errorf("повторное проигрывание изменило число строк реестра: %d → %d",
			len(values), second.Categories)
	}

	var withCode, withoutCode int
	for _, v := range values {
		if v.Code == "" {
			withoutCode++
			continue
		}
		withCode++
		// Всякий выведенный код обязан быть каноническим: обе формы имени,
		// которые Apple дала одному значению, сходятся в одну (находка 43).
		// Координаты — метрика и поле; сам код не печатается: он константа
		// бинаря, но напечатанный рядом с живым архивом сообщает, что эта фаза
		// у человека была.
		if healthkit.Canonical(v.Code) != v.Code {
			t.Errorf("%s/%s: выведенный код не канонический", v.Metric, v.Field)
		}
		// Провенанс — доставка журнала, а не пустое место.
		if v.FirstDeliveryID == "" || v.FirstSeen.IsZero() {
			t.Errorf("%s/%s: провенанс не заполнен", v.Metric, v.Field)
		}
	}

	// Фазы сна — единственное, что словарь покрывает, и покрывать он их обязан:
	// ради этой сверки задача и существует. Строки берутся из ЖИВОГО архива, а
	// не из литерала теста.
	var sleepValues, sleepCoded int
	for _, v := range values {
		if v.Metric != "sleep_analysis" || v.Field != "value" {
			continue
		}
		sleepValues++
		if v.Code != "" {
			sleepCoded++
		}
	}
	if sleepValues == 0 {
		t.Fatal("фаз сна в реестре нет — проверять нечего")
	}
	if sleepCoded != sleepValues {
		// Печатается ЧИСЛО, а не перечень. Перечень был бы удобнее, и ровно
		// поэтому его тут быть не может: прогон идёт против рабочего архива, а
		// фаза сна — значение точки. `CLAUDE.md`, «Запреты»: ничего из `./data`
		// не попадает ни в логи выше `DEBUG`, ни в вывод агента. Ветка
		// срабатывает как раз тогда, когда телефон принёс НОВУЮ фазу, то есть
		// именно тогда, когда соблазн напечатать её сильнее всего.
		//
		// Адрес, по которому строки смотрят, отпечатывать не нужно: они лежат в
		// пересобранной базе, `SELECT value FROM category_value WHERE code = ''`.
		t.Errorf("фаз сна без кода %d из %d — словарь неполон; строки смотреть в реестре пересобранной базы",
			sleepValues-sleepCoded, sleepValues)
	}

	// Числа печатаются: они растут с корпусом и утверждению не подлежат.
	// Значения при этом НЕ печатаются — это данные о здоровье.
	t.Logf("реестр: строк %d, с кодом %d, без кода %d; фаз сна %d, все с кодом",
		len(values), withCode, withoutCode, sleepValues)
}

// measureStyles прогоняет измерение рода агрегации на витрине, собранной из
// живого архива, — единственное место, где правило проверяется на настоящем
// потоке, а не на фикстурах.
//
// Утверждаются СВОЙСТВА, а не числа: корпус растёт с каждой доставкой, а прогон
// живого архива в гейт не входит, так что константа, производная от размера
// корпуса, покраснела бы молча (docs/review.md, 2026-08-02). Измеренные
// числа печатаются.
func measureStyles(t *testing.T, st *store.Store) {
	t.Helper()

	started := time.Now()
	metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(slog.DiscardHandler)))
	if err != nil {
		t.Fatalf("каталог: %v", err)
	}
	elapsed := time.Since(started)

	byStyle := map[catalog.Style]int{}
	for _, m := range metrics {
		byStyle[m.Aggregation.Style]++

		// Главное свойство правила: свидетельства единогласны. Противоречие —
		// событие для разбора, а не отказ прогона, поэтому оно печатается с
		// координатами и валит тест: пока его нет, посылка «род измерим» верна.
		if m.Aggregation.Conflicting > 0 {
			t.Errorf("%s: противоречащих часов %d при %d согласных — свидетельства разошлись",
				m.Metric, m.Aggregation.Conflicting, m.Aggregation.Agreeing)
		}

		// Род измеряется только сверкой минутного слоя с часовым. Метрика с
		// объявленным родом обязана иметь оба слоя: иначе он выведен из
		// нижнего, а нижний слой HAE — посекундная развёртка, и его сумма
		// завышена.
		if m.Aggregation.Style == catalog.Unknown {
			continue
		}
		var hasMinute, hasHour bool
		for _, l := range m.Layers {
			hasMinute = hasMinute || l.Layer == string(hae.LayerMinute)
			hasHour = hasHour || l.Layer == string(hae.LayerHour)
		}
		if !hasMinute || !hasHour {
			t.Errorf("%s: род %v при слоях %+v — измерять было нечем", m.Metric, m.Aggregation.Style, m.Layers)
		}
	}

	// Роды разошлись: правило различает накопительные и мгновенные, а не
	// сваливает всё в одну кучу и не отвечает `unknown` на весь корпус.
	if byStyle[catalog.Cumulative] == 0 {
		t.Error("накопительных метрик не нашлось — правило не различает роды")
	}
	if byStyle[catalog.Instant] == 0 {
		t.Error("мгновенных метрик не нашлось — правило не различает роды")
	}

	t.Logf("каталог: метрик %d за %v; накопительных %d, мгновенных %d, неизвестных %d",
		len(metrics), elapsed.Round(time.Millisecond),
		byStyle[catalog.Cumulative], byStyle[catalog.Instant], byStyle[catalog.Unknown])
	for _, m := range metrics {
		t.Logf("  %-38s %-10s часов %d, пригодных %d, согласных %d",
			m.Metric, m.Aggregation.Style, m.Aggregation.Hours,
			m.Aggregation.Compared, m.Aggregation.Agreeing)
	}
}

// countSleepKeys возвращает число различных координат записей сна и число
// различных меток начала. Разница между ними и есть то, что теряет ключ по
// метке.
//
// Каталог разрезов — отдельная задача, поэтому здесь перебор по известным
// слоям, а не запрос к нему.
func countSleepKeys(t *testing.T, st *store.Store) (coords, labels int) {
	t.Helper()

	type key struct{ start, end int64 }
	ctx := context.Background()
	seenCoord := map[key]struct{}{}
	seenLabel := map[int64]struct{}{}

	for _, layer := range []string{"sample", "raw", "minute", "hour", "day"} {
		hours, err := st.BucketHours(ctx, "sleep_analysis", layer)
		if err != nil {
			t.Fatalf("часы объектов: %v", err)
		}
		for _, h := range hours {
			b, err := st.Bucket(ctx, "sleep_analysis", layer, h)
			if err != nil {
				t.Fatalf("чтение объекта: %v", err)
			}
			for _, p := range b.Points {
				seenCoord[key{p.Start.UnixNano(), p.End.UnixNano()}] = struct{}{}
				seenLabel[p.Start.UnixNano()] = struct{}{}
			}
		}
	}
	return len(seenCoord), len(seenLabel)
}

func collectBodies(t *testing.T, root string) []string {
	t.Helper()

	var out []string
	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return nil //nolint:nilerr // архива может не быть — это не отказ теста
		}
		if !info.IsDir() && filepath.Ext(path) == ".gz" {
			out = append(out, path)
		}
		return nil
	})
	if err != nil {
		return nil
	}
	sort.Strings(out)
	return out
}

func gunzip(t *testing.T, body []byte) []byte {
	t.Helper()

	gz, err := gzip.NewReader(bytes.NewReader(body))
	if err != nil {
		t.Fatalf("распаковка: %v", err)
	}
	defer func() { _ = gz.Close() }()

	out, err := io.ReadAll(gz)
	if err != nil {
		t.Fatalf("чтение: %v", err)
	}
	return out
}

// metricsOf — список метрик каталога без версии витрины: версию проверяют
// отдельные тесты, остальным нужен только состав ответа.
func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) {
	snap, err := s.Metrics(ctx)
	return snap.Metrics, err
}
