// Package fold — свёртка доставки в часовые объекты.
//
// Хранилище — свёртка по журналу: состояние пересобирается как
// `import(экспорт) + replay(доставки по received_at)`. Поэтому свёртка
// принимает идентификатор доставки, а тело читает из архива — тем же кодом,
// каким его прочитает пересборка. Разбор «из памяти, раз тело всё равно в
// руках» дал бы второй путь, который разошёлся бы с первым молча.
package fold

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"strings"
	"time"

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

// defaultMaxBodyBytes — граница размера тела при чтении из архива, когда
// вызывающий свою не задал.
//
// Наблюдалось 42 МиБ. Граница явная, потому что молчаливое «сколько дадут» —
// это отказ, который проявится только на пике потока: битый или враждебный
// файл архива выест память процесса.
const defaultMaxBodyBytes = 64 << 20

// Service сворачивает доставки в часовые объекты.
type Service struct {
	arch  *archive.Archive
	store *store.Store
	log   *slog.Logger

	// maxBody — та же граница, что у приёма, и это существенно. Тело между
	// границами приёма и свёртки было бы принято со статусом 200, легло бы в
	// архив и потом вечно валилось бы при каждой пересборке.
	maxBody int64
}

// New собирает свёртку. maxBody — граница размера РАСПАКОВАННОГО тела; ноль
// означает умолчание.
func New(arch *archive.Archive, st *store.Store, maxBody int64, log *slog.Logger) *Service {
	if maxBody <= 0 {
		maxBody = defaultMaxBodyBytes
	}
	return &Service{
		arch:    arch,
		store:   st,
		maxBody: maxBody,
		log:     log.With("capability", "fold"),
	}
}

// finishTimeout — сколько отводится записи исхода свёртки.
//
// Исход пишется на контексте, ПЕРЕЖИВАЮЩЕМ отмену исходного: иначе при
// срабатывании дедлайна свёртки запись статуса гарантированно провалится, и
// доставка навсегда останется `pending` — притом что часть объектов уже
// записана. То есть ровно в том случае, ради которого дедлайн и заведён,
// учёт разошёлся бы с содержимым витрины.
const finishTimeout = 10 * time.Second

// Stats — итог свёртки одной доставки.
//
// Счётчики слияния ВСТРОЕНЫ, а не переписаны полем в поле: ручное копирование
// молча теряет новый счётчик, а по одному из них (удержанная обеднённая версия
// сущности) принято решение не объединять поля — забытая строка присваивания
// отменила бы наблюдение при зелёных тестах хранилища.
type Stats struct {
	store.MergeStats

	Metrics          int
	Points           int
	SkippedNoTime    int
	SkippedMalformed int
	SkippedBadEnd    int
	// Счётчики пропуска сущностей: у каждого класса свой, потому что тело в
	// архиве остаётся, а вернуть сущность может только пересборка.
	SkippedNoID            int
	SkippedEntityNoTime    int
	SkippedEntityMalformed int
	Layer                  string
	LayerMismatch          bool
	// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает.
	// Это ответ на вопрос «что останется потерянным, если тело удалить».
	Uncovered []string
	// UncoveredDropped — сколько имён отброшено границей списка.
	UncoveredDropped int
	// UncoveredNew — имена непокрытых секций, которых не было ни в одной
	// доставке, стоящей в журнале раньше этой. Событие однократное за всю жизнь
	// имени: поток дописывает метрики на телефоне молча, и момент появления
	// секции наблюдать больше нечем.
	UncoveredNew []string
	// UncoveredSeenUnknown — сверка с журналом не состоялась, и потому все
	// непокрытые имена доставки объявлены новыми. Лишняя запись стоит внимания
	// один раз, промолчавшее событие не восстанавливается ничем.
	UncoveredSeenUnknown bool
	// UncoveredSeenError — почему не состоялась.
	UncoveredSeenError error

	// Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось;
	// CategoricalUnknown — сколько из них словарь не знает;
	// CategoricalDropped — сколько отброшено границами разбора.
	//
	// Только числа: сами строки — данные о здоровье, контекст пульса и фаза сна
	// описывают человека не меньше, чем число.
	Categoricals       int
	CategoricalUnknown int
	CategoricalDropped int
}

// ErrPanicked — свёртка паниковала. Доставка получает `failed`: тело в архиве, и
// пересборка вернёт её, когда дефект будет исправлен.
var ErrPanicked = errors.New("свёртка паниковала")

// Fold разбирает тело доставки и раскладывает точки по часовым объектам.
//
// Это единственный логирующий чекпоинт свёртки: транспорт и приём исход
// разбора не логируют. Значения точек и имена устройств в лог не попадают —
// данные о здоровье чувствительнее токенов.
//
// Паника перехватывается ЗДЕСЬ, у той же границы, что пишет исход разбора.
// Пока свёртка шла внутри HTTP-обработчика, панику ловил middleware.Recoverer и
// она стоила одного ответа; из фоновой горутины она валит процесс целиком, а
// `restart: unless-stopped` поднимает его снова — и первый же проход берёт ту
// же доставку, то есть дефект превращается в цикл перезапуска, при котором
// приём не работает вовсе. Перехват у этой границы, а не у вызывающего,
// оставляет писателя `parse_status` единственным.
func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err error) {
	defer func() {
		r := recover()
		if r == nil {
			return
		}
		stats = Stats{}
		err = fmt.Errorf("%w: %v", ErrPanicked, r) //nolint:errorlint // причину раскрываем текстом, sentinel — для ветвления
		s.fail(ctx, deliveryID, err, parseResidue{})
	}()

	d, err := s.store.DeliveryForParse(ctx, deliveryID)
	if err != nil {
		// Ни один выход с ошибкой не молчит: вызывающий эту ошибку сознательно
		// отбрасывает, и молчащий путь означал бы доставку вообще без событий
		// в журнале — её не найти ни по какому запросу.
		s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID)
		return stats, err
	}

	body, err := s.readBody(d.RawPath)
	if err != nil {
		s.fail(ctx, deliveryID, err, parseResidue{})
		return stats, err
	}

	fallback, err := s.store.LastDerivedLayer(ctx, d.AutomationID, d.ReceivedAt, d.ID)
	if err != nil {
		s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID)
		return stats, err
	}

	parsed, err := hae.Parse(body, hae.Meta{
		Aggregation:   d.Aggregation,
		FallbackLayer: hae.Layer(fallback),
		Locale:        localeOf(d.Headers),
	})

	// Сверка с журналом идёт ДО ветвления на успех и отказ. Список непокрытых
	// секций переживает отказ разбора, то есть имя уже записано в учёт; смолчи
	// здесь — и следующая доставка сочтёт его виденным, а событие не вернётся
	// ничем, кроме ручного запроса в базу.
	novelty := s.novelty(ctx, parsed.Uncovered, store.DeliveryRef{
		ID:         d.ID,
		ReceivedAt: d.ReceivedAt,
	})

	if err != nil {
		// Список непокрытых секций переживает отказ: доставка, у которой не
		// определился слой, обязана остаться записью о том, что в теле есть
		// невосстановимая секция.
		//
		// А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший
		// ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и
		// записать этот ноль значило бы объявить доставку проверенной.
		s.fail(ctx, deliveryID, err, residueOf(parsed, novelty))
		return stats, err
	}

	stats.Uncovered = parsed.Uncovered
	stats.UncoveredDropped = parsed.UncoveredDropped
	stats.UncoveredNew = novelty.fresh
	stats.UncoveredSeenUnknown = novelty.unknown
	stats.UncoveredSeenError = novelty.cause
	stats.Metrics = parsed.Metrics
	stats.Points = len(parsed.Points)
	stats.SkippedNoTime = parsed.SkippedNoTime
	stats.SkippedMalformed = parsed.SkippedMalformed
	stats.SkippedBadEnd = parsed.SkippedBadEnd
	stats.SkippedNoID = parsed.SkippedNoID
	stats.SkippedEntityNoTime = parsed.SkippedEntityNoTime
	stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed
	stats.Layer = string(parsed.Layer)
	stats.LayerMismatch = parsed.LayerMismatch
	stats.Categoricals = len(parsed.Categoricals)
	stats.CategoricalUnknown = parsed.CategoricalUnknown
	stats.CategoricalDropped = parsed.CategoricalDropped

	merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{
		ID:         d.ID,
		ReceivedAt: d.ReceivedAt,
	})
	if err != nil {
		// Здесь разбор досчитал: отказало слияние. Значит счётчик пропусков
		// измерен и обязан дойти до учёта — в отличие от ветки выше.
		s.fail(ctx, deliveryID, err, parseResidue{
			uncovered: parsed.Uncovered,
			skipped:   skippedEntities(parsed),
			// Новизна доезжает и сюда. Эта ветвь пишет имя в учёт ровно так же,
			// как ветвь отказа разбора, — значит и терять событие ей нельзя:
			// следующая доставка сочтёт имя виденным, а отказ слияния бывает
			// нетранзиентным (исчерпанный дедлайн свёртки под большим телом).
			novelty: novelty,
		})
		return stats, err
	}
	stats.MergeStats = merge

	// Источник истины — список; статус производен от него и от факта отказа.
	// Приоритет назван явно, иначе два будущих читателя (ретеншен и /stats)
	// разойдутся: один спросит parse_status, другой — непустоту списка.
	status := store.ParseDone
	if len(parsed.Uncovered) > 0 {
		status = store.ParsePartial
	}
	out := store.ParseOutcome{
		Status:          status,
		Points:          int64(stats.Points),
		Layer:           stats.Layer,
		Uncovered:       parsed.Uncovered,
		SkippedEntities: skippedEntities(parsed),
	}
	if err := s.finish(ctx, deliveryID, out); err != nil {
		s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID)
		return stats, err
	}

	s.logResult(ctx, deliveryID, stats)
	return stats, nil
}

// logResult — единственный логирующий чекпоинт свёртки.
//
// Все признаки идут АТРИБУТАМИ всегда, а уровень выбирается отдельно. Раньше
// признаки жили только в тексте сообщения, и `switch` их терял: доставка,
// одновременно задевшая запечатанный час и разошедшаяся с заголовком, не
// оставляла следа о слое вовсе — а это единственный индикатор того, что слой
// выводится неправильно.
//
// Значений точек и имён устройств здесь нет и быть не может: данные о здоровье
// чувствительнее токенов. Координаты столкновений — метрика, слой, час — не
// значения.
func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
	skipped := st.SkippedNoTime + st.SkippedMalformed + st.SkippedBadEnd
	skippedEntities := st.SkippedNoID + st.SkippedEntityNoTime + st.SkippedEntityMalformed

	attrs := []any{
		"delivery_id", deliveryID,
		"metrics", st.Metrics,
		"points", st.Points,
		"stored", st.Stored,
		"buckets", st.Buckets,
		"unchanged", st.Unchanged,
		"overwrites", st.Overwrites,
		// Удержания точек идут атрибутом, а не отдельной ветвью WARN: под новым
		// тай-брейком это правило полноты, работающее штатно (на живом корпусе
		// 981 координата из 80 129 спорных), и эскалация обесценила бы уровень.
		// Наблюдаемым событие делает то, что оно посчитано отдельно от
		// перезаписей и печатается прогоном пересборки.
		"points_held", st.PointsHeld,
		// Разрушительное направление считается отдельно и ЭСКАЛИРУЕТСЯ ниже:
		// на живом корпусе это 2 координаты из 80 129 спорных, шквала не будет,
		// а событие означает потерю содержания — обратимую пересборкой, пока
		// жив архив.
		"points_erased", st.PointsErased,
		"incomparable", st.Incomparable,
		"units_conflicts", st.UnitsConflicts,
		"sealed_hits", st.SealedHits,
		"skipped", skipped,
		"skipped_no_time", st.SkippedNoTime,
		"skipped_malformed", st.SkippedMalformed,
		"skipped_bad_end", st.SkippedBadEnd,
		// Сущности с собственным `id`: пришло, легло и удержано. Координаты
		// (род и идентификатор) разрешены, содержимое — нет: маршрут
		// тренировки это геотрек до дома, а метки состояния разума —
		// измерение душевного состояния.
		"workouts", st.Workouts,
		"workouts_written", st.WorkoutsWritten,
		"records", st.Records,
		"records_written", st.RecordsWritten,
		"entities_held", st.EntitiesHeld,
		"entities_diverging", st.EntitiesDiverging,
		"skipped_entities", skippedEntities,
		"layer", st.Layer,
		"layer_mismatch", st.LayerMismatch,
		// Структурным []string, а не склейкой: JSON-кодировщик slog экранирует
		// управляющие символы, поэтому имя секции из чужого тела не разрывает
		// построчный разбор логов. Содержимого секций здесь нет.
		"uncovered", st.Uncovered,
		"uncovered_dropped", st.UncoveredDropped,
		// Имена, встреченные впервые по журналу, — атрибутом ВСЕГДА, а уровень
		// поднимается отдельной ветвью ниже. Наблюдаемый признак события это
		// он: имя непокрытой секции стоит в атрибуте `uncovered` у каждой
		// доставки, которая её принесла, и по нему первую встречу не отличить.
		"uncovered_new", st.UncoveredNew,
		"uncovered_seen_unknown", st.UncoveredSeenUnknown,
		// Причина несостоявшейся сверки — рядом с признаком. Занятость базы
		// проходит сама, испорченная колонка не проходит никогда и поднимает
		// признак на каждой доставке; по одному булеву это неразличимо.
		// Значений точек в ошибке нет: до текста доезжает только имя секции, и
		// оно обрезано.
		"uncovered_seen_error", st.UncoveredSeenError,
		// Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов:
		// «Сидячий образ жизни» — это контекст пульса, то есть данные о
		// здоровье. Какие именно строки ждут словаря, отвечает реестр в базе.
		"categoricals", st.Categoricals,
		"categoricals_unknown", st.CategoricalUnknown,
		"categoricals_dropped", st.CategoricalDropped,
	}
	if len(st.Collisions) > 0 {
		attrs = append(attrs, "collisions", formatCollisions(st.Collisions))
	}
	if len(st.IncomparableAt) > 0 {
		attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
	}
	if len(st.PointsHeldAt) > 0 {
		attrs = append(attrs, "points_held_at", formatCollisions(st.PointsHeldAt))
	}
	if len(st.PointsErasedAt) > 0 {
		attrs = append(attrs, "points_erased_at", formatCollisions(st.PointsErasedAt))
	}
	if len(st.HeldAt) > 0 {
		attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt))
	}
	if len(st.DivergingAt) > 0 {
		attrs = append(attrs, "diverging_at", formatEntityRefs(st.DivergingAt))
	}

	// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
	// штатная работа. Без этого условия смена формата метки выглядела бы как
	// здоровый поток: 200, parsed, INFO, points=0.
	allSkipped := st.Points == 0 && skipped > 0
	// То же для сущностей: доставка из одних тренировок, у которой не осталось
	// ни одной, — это сменившийся формат, а не пустая секция.
	allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0

	switch {
	case len(st.UncoveredNew) > 0:
		// ПЕРВОЙ ветвью, и это существенно. Все прочие говорят о событиях,
		// повторяющихся на живом потоке; это — однократное за всю жизнь имени, и
		// замаскировать его перезаписью точек значило бы потерять ровно то, ради
		// чего наблюдение заведено. Уровень `WARN`, а не `ERROR`: приезд новой
		// секции — штатное событие внешнего мира, «посмотри», а не «разбери
		// сбой». Имена секций в лог попадать могут: имя ключа — форма пакета, а
		// не измерение.
		s.log.WarnContext(ctx, "delivery folded, new uncovered section", attrs...)
	case st.EntitiesHeld > 0:
		// Приехавшая версия сущности отклонена как теряющая содержание. Плата
		// за отказ объединять поля: событие обязано быть видно, потому что на
		// живом потоке оно не наступало ни разу и правило держится на этом.
		s.log.WarnContext(ctx, "delivery folded, poorer entity version held", attrs...)
	case st.EntitiesDiverging > 0:
		// Событие другого рода и с другим лечением: в одном теле приехали
		// версии одного ключа с разным содержанием. Победитель лёг в витрину
		// целиком, терять нечего — но корпус такого не производил, и молчать
		// об этом нельзя. Отдельной ветвью, а не общей с удержанием: сообщение
		// «удержана обеднённая версия» отправляло бы владельца искать то, чего
		// не случилось.
		s.log.WarnContext(ctx, "delivery folded, entity versions diverge in one body", attrs...)
	case allEntitiesSkipped:
		s.log.WarnContext(ctx, "delivery folded, all entities skipped", attrs...)
	case st.UncoveredDropped > 0:
		// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
		// а границу выбило больше тридцати двух.
		s.log.WarnContext(ctx, "delivery folded, uncovered section list truncated", attrs...)
	case st.CategoricalDropped > 0:
		// Тот же класс события и та же причина эскалации, что у списка секций:
		// границы подобраны по измерению (на живом потоке около одиннадцати
		// различных значений, самое длинное 36 байт), поэтому попадание в них —
		// уже аномалия, а не режим. В INFO оно утонуло бы: поток идёт раз в пять
		// минут, а `/stats` ещё нет. Соответствие «доставка → отброшено» живёт
		// только здесь: лог ротируется, и к следующей сверке отпечатков причину
		// уже не восстановить.
		//
		// `CategoricalUnknown` намеренно НЕ эскалируется: он штатно ненулевой —
		// словарь покрывает только фазы сна, — и постоянный WARN обесценил бы
		// уровень.
		s.log.WarnContext(ctx, "delivery folded, categorical values dropped by limit", attrs...)
	case st.Incomparable > 0:
		// Выше перезаписей намеренно: несравнимый набор полей — событие реже и
		// информативнее, на живом потоке не случавшееся ни разу. Признаки при
		// этом идут атрибутами всегда, так что выбор ветви ничего не прячет.
		s.log.WarnContext(ctx, "delivery folded, incomparable point fields", attrs...)
	case st.PointsErased > 0:
		// НИЖЕ несравнимости, хотя событие тяжелее: на живом корпусе эти два
		// множества совпадают (2 координаты и там, и там), и несравнимость
		// сообщает больше — она называет ещё и то, что объединять поля было бы
		// что. Ветвь эта говорит про случай, которого несравнимость не
		// покрывает: сохранённая была надмножеством по именам, но значения
		// общих ключей разошлись, разряд полноты погас, и содержание унесла
		// пришедшая. Счётчики обеих ветвей идут атрибутами всегда, так что
		// порядок ветвей ничего не прячет.
		s.log.WarnContext(ctx, "delivery folded, arriving point dropped stored field", attrs...)
	case st.Overwrites > 0:
		// Единственное наблюдение, по которому проверяется правило слияния.
		// В INFO оно тонуло: поток идёт раз в пять минут.
		s.log.WarnContext(ctx, "delivery folded, points overwritten", attrs...)
	case st.UnitsConflicts > 0:
		s.log.WarnContext(ctx, "delivery folded, units differ from stored", attrs...)
	case st.SealedHits > 0:
		// Досчёт часа, в который его уже не ждали: единственное наблюдение, по
		// которому вообще можно судить о глубине досчёта.
		s.log.WarnContext(ctx, "delivery folded, sealed hour changed", attrs...)
	case allSkipped:
		s.log.WarnContext(ctx, "delivery folded, all points skipped", attrs...)
	case st.LayerMismatch:
		// Расхождение сверяется только с надёжным заголовком: `Default` не
		// означает режима, и сравнение с ним давало бы WARN на каждой доставке.
		s.log.WarnContext(ctx, "delivery folded, layer differs from header", attrs...)
	default:
		s.log.InfoContext(ctx, "delivery folded", attrs...)
	}
}

// formatCollisions превращает координаты столкновений в строку для лога.
func formatCollisions(cs []store.Collision) string {
	parts := make([]string, 0, len(cs))
	for _, c := range cs {
		parts = append(parts, c.Metric+"/"+c.Layer+"@"+store.FormatTime(c.HourUTC))
	}
	return strings.Join(parts, " ")
}

// formatEntityRefs превращает координаты сущностей в строку для лога.
// Содержимого не несёт: род и идентификатор — координаты, а не измерение.
func formatEntityRefs(refs []store.EntityRef) string {
	parts := make([]string, 0, len(refs))
	for _, r := range refs {
		parts = append(parts, r.Kind+"/"+r.ID)
	}
	return strings.Join(parts, " ")
}

// keepLayer — значение слоя, означающее «оставить как было».
const keepLayer = ""

// finish записывает исход разбора на контексте, переживающем отмену исходного.
func (s *Service) finish(ctx context.Context, deliveryID string, out store.ParseOutcome) error {
	ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), finishTimeout)
	defer cancel()

	if err := s.store.FinishParse(ctx, deliveryID, out); err != nil {
		return fmt.Errorf("запись исхода разбора: %w", err)
	}
	return nil
}

// fail записывает исход неудачной свёртки.
//
// Исход отражает ДОСТАВКУ, а не обстоятельства. Отмена снаружи и занятость базы
// работой доставки не являются: они означают «не сделано», а не «не выходит».
// Статус в этих случаях не трогается вовсе — доставка остаётся `pending` и
// подбирается следующим проходом. Иначе конкуренция за базу (после разнесения
// ответа и свёртки она штатная) выводила бы доставку из очереди навсегда:
// `failed` возвращает только пересборка, то есть ручная операция с остановкой
// сервиса.
//
// Всё прочее — непонятое содержимое, невыводимый слой, нечитаемое или слишком
// большое тело, исчерпанный дедлайн — свойства самой доставки, и повторять их
// бесполезно: статус `failed`, тело ждёт пересборки. Приём при этом не
// затрагивается: сохранили значит приняли.
// parseResidue — то, что разбор успел узнать о доставке до отказа и что обязано
// пережить его в учёте: список непокрытых секций и число пропущенных сущностей.
//
// Структурой, а не двумя параметрами: у `fail` их стало бы четыре, и следующий
// счётчик неизбежно перепутали бы местами с предыдущим. Пустое значение —
// «разбор до этого не дошёл», и оно честно: отказ на чтении тела ничего о
// содержимом не знает.
type parseResidue struct {
	uncovered []string
	// skipped — nil означает «разбор до конца не дошёл, пропусков никто не
	// считал». Ноль означал бы «проверено, терять нечего», а по этому числу
	// ретеншен принимает необратимое решение об удалении тела.
	skipped *int64
	// novelty в учёте не участвует — она едет в запись лога об отказе. Полем, а
	// не пятым параметром `fail`: параметры путают местами, а поле называет
	// себя само.
	novelty sectionNovelty
}

func residueOf(parsed hae.Result, novelty sectionNovelty) parseResidue {
	return parseResidue{uncovered: parsed.Uncovered, novelty: novelty}
}

// sectionNovelty — исход сверки имён непокрытых секций с журналом.
type sectionNovelty struct {
	// fresh — имена, которых не было ни в одной доставке раньше этой.
	fresh []string
	// unknown — сверка не состоялась, и потому новыми объявлены ВСЕ имена
	// доставки.
	unknown bool
	// cause — почему не состоялась. Без неё занятость базы (пройдёт сама) и
	// испорченное содержимое колонки (не пройдёт никогда, и признак будет
	// подниматься на каждой доставке) неотличимы, а разбираться пришлось бы тем
	// самым ручным запросом в базу, от которого задача избавляет.
	cause error
}

// novelty спрашивает журнал, какие из непокрытых имён встречаются впервые.
//
// Отказ запроса свёртку не роняет и исходом доставки не становится: правила
// классификации исходов наблюдение не трогает, занятая база и отменённый
// контекст остаются обстоятельствами. Но и молчания здесь быть не может — имя,
// о котором смолчали, уже записано в учёт, — поэтому при отказе новыми
// объявляются все имена, а признак несостоявшейся сверки идёт в запись.
//
// Запрос берётся только при непустом списке: на живом потоке все три
// приезжающие секции покрыты, то есть в штатном режиме сверка не стоит ничего.
func (s *Service) novelty(ctx context.Context, uncovered []string, at store.DeliveryRef) sectionNovelty {
	if len(uncovered) == 0 {
		return sectionNovelty{}
	}

	seen, err := s.store.SectionsSeenBefore(ctx, uncovered, at)
	if err != nil {
		return sectionNovelty{fresh: uncovered, unknown: true, cause: err}
	}

	fresh := make([]string, 0, len(uncovered))
	for _, name := range uncovered {
		if _, ok := seen[name]; ok {
			continue
		}
		fresh = append(fresh, name)
	}
	if len(fresh) == 0 {
		return sectionNovelty{}
	}
	return sectionNovelty{fresh: fresh}
}

// skippedEntities — сколько сущностей с собственным `id` разбор пропустил.
// Сумма трёх классов, а не три колонки: ретеншен спрашивает «есть ли что
// терять», а не «почему», а разбор класса живёт в логе свёртки, где все три
// счётчика идут атрибутами.
func skippedEntities(parsed hae.Result) *int64 {
	n := int64(parsed.SkippedNoID + parsed.SkippedEntityNoTime + parsed.SkippedEntityMalformed)
	return &n
}

func (s *Service) fail(ctx context.Context, deliveryID string, cause error, residue parseResidue) {
	if store.Transient(cause) {
		// WARN, а не ERROR: пройдёт само, разбирать нечего. Строка нужна, чтобы
		// повтор не выглядел беспричинным.
		s.log.WarnContext(ctx, "delivery fold deferred", "error", cause, "delivery_id", deliveryID)
		return
	}

	// Новые имена доезжают до записи об отказе: она уже выше рутинного уровня,
	// а событие опознаётся атрибутом. В отложенном исходе выше их нет намеренно
	// — там учётная запись не меняется, доставка вернётся следующим проходом, и
	// повторение признака на каждом проходе занятой базы превратило бы
	// однократное событие в дребезг.
	attrs := []any{"error", cause, "delivery_id", deliveryID}
	if len(residue.novelty.fresh) > 0 {
		attrs = append(attrs, "uncovered_new", residue.novelty.fresh)
	}
	if residue.novelty.unknown {
		attrs = append(attrs, "uncovered_seen_unknown", true,
			"uncovered_seen_error", residue.novelty.cause)
	}

	level := slog.LevelError
	switch {
	case errors.Is(cause, hae.ErrLayerUnknown):
		// Слой не определился — это не поломка, а ожидаемый исход для доставки
		// без плотных метрик. Тело ждёт пересборки.
		level = slog.LevelWarn
	case errors.Is(cause, hae.ErrMalformed):
		// Непонятое содержимое от отправителя — норма жизни, разбирать нечего.
		// На границе приёма такой же отказ уходит в DEBUG; два разных уровня у
		// одного класса ошибки давали бы постоянный ERROR-шум.
		level = slog.LevelWarn
	}
	s.log.Log(ctx, level, "delivery fold failed", attrs...)

	// Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая
	// строка здесь оборвала бы цепочку наследования, то есть изменила бы
	// результат пересборки журнала.
	out := store.ParseOutcome{
		Status:          store.ParseFailed,
		Layer:           keepLayer,
		Uncovered:       residue.uncovered,
		SkippedEntities: residue.skipped,
	}
	if err := s.finish(ctx, deliveryID, out); err != nil {
		s.log.ErrorContext(ctx, "delivery parse status not recorded", "error", err, "delivery_id", deliveryID)
	}
}

// ReadBody читает тело из архива с той же границей размера, что и свёртка.
//
// Экспортировано ради пересборки: ей нужно прочесть тело, у которого ещё нет
// учётной записи, чтобы посчитать размер и хеш. Своей копией чтения это делать
// нельзя — граница обязана быть общей, иначе тело, принятое приёмом со `200`,
// начнёт вечно отказывать на каждой пересборке, и договорённость «предел тот
// же» ничем не проверяется.
func (s *Service) ReadBody(rawPath string) ([]byte, error) {
	return s.readBody(rawPath)
}

func (s *Service) readBody(rawPath string) ([]byte, error) {
	r, err := s.arch.Open(rawPath)
	if err != nil {
		return nil, err
	}
	defer func() { _ = r.Close() }()

	body, err := io.ReadAll(io.LimitReader(r, s.maxBody+1))
	if err != nil {
		return nil, fmt.Errorf("чтение тела из архива: %w", err)
	}
	if int64(len(body)) > s.maxBody {
		return nil, fmt.Errorf("тело больше %d байт", s.maxBody)
	}
	return body, nil
}

func toIncoming(parsed hae.Result) store.Incoming {
	points := make([]store.IncomingPoint, 0, len(parsed.Points))
	for _, p := range parsed.Points {
		points = append(points, store.IncomingPoint{
			Metric: p.Metric,
			Layer:  string(p.Layer),
			Units:  p.Units,
			Point: store.Point{
				Start:         p.Start,
				End:           p.End,
				OffsetSeconds: p.OffsetSeconds,
				Raw:           p.Raw,
			},
		})
	}
	return store.Incoming{
		Points:     points,
		Workouts:   toEntities(parsed.Workouts),
		Records:    toEntities(parsed.Records),
		Categories: toCategories(parsed.Categoricals),
	}
}

func toCategories(in []hae.Categorical) []store.CategoryValue {
	if len(in) == 0 {
		return nil
	}
	out := make([]store.CategoryValue, 0, len(in))
	for _, c := range in {
		out = append(out, store.CategoryValue{
			Metric: c.Metric,
			Field:  c.Field,
			Value:  c.Value,
			Code:   c.Code,
		})
	}
	return out
}

// localeOf достаёт язык доставки из сохранённых заголовков запроса.
//
// Отдельной колонки под язык нет намеренно: заголовки уже лежат целиком, и
// вторая копия того же факта разошлась бы с первой при первой же правке приёма.
//
// Отсутствие заголовка, неразбираемый JSON и любая другая неожиданность дают
// пустую локаль, а не отказ. Пустая локаль законна и вывода кода не отменяет:
// заголовков в сыром архиве нет вовсе, поэтому доставка, восстановленная из
// осиротевшего тела, приезжает без них — и обязана дать то же состояние.
//
// Имя заголовка ищется без учёта регистра: `http.Header` канонизирует его при
// приёме, но в базе лежит то, что было записано, и правило регистра не наше.
func localeOf(headers string) string {
	if headers == "" {
		return ""
	}
	var h map[string][]string
	if err := json.Unmarshal([]byte(headers), &h); err != nil {
		return ""
	}
	for name, values := range h {
		if !strings.EqualFold(name, acceptLanguageHeader) || len(values) == 0 {
			continue
		}
		return healthkit.Locale(values[0])
	}
	return ""
}

const acceptLanguageHeader = "Accept-Language"

func toEntities(in []hae.Entity) []store.IncomingEntity {
	if len(in) == 0 {
		return nil
	}
	out := make([]store.IncomingEntity, 0, len(in))
	for _, e := range in {
		out = append(out, store.IncomingEntity{
			ID:            e.ID,
			Kind:          e.Kind,
			Name:          e.Name,
			Start:         e.Start,
			End:           e.End,
			OffsetSeconds: e.OffsetSeconds,
			Duration:      e.Duration,
			Raw:           e.Raw,
		})
	}
	return out
}
