// Package hae — разбор тела доставки Health Auto Export в точки.
//
// Отдельно от хранения потому, что у разбора будет второй потребитель
// (пересборка витрины из архива) и второй источник (родной экспорт Apple —
// свой формат поверх того же хранилища). Пакет ничего не знает ни про SQLite,
// ни про архив, и ничего не пишет: `Parse` — чистая функция от тела и
// заголовков.
//
// Правила разбора выведены измерением живого потока, а не спроектированы:
// docs/research/apple-health.md, находки 2, 30, 33, 35, 36, 38, 39, 47. Документация
// HAE местами расходится с тем, что приложение шлёт на самом деле, поэтому
// источник истины по формату — пакеты в testdata.
package hae

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"sort"
	"time"
	"unicode/utf8"
)

// Layer — подробность, в которой метрика приехала. Выводится из выравнивания
// меток времени, а не из заголовка доставки: заголовок `Default` наблюдался
// одновременно у посекундного, минутного и часового режимов (находка 33).
type Layer string

// Слои хранения. `sample` появится с импортом родного экспорта Apple, `day`
// назначается схемам с фиксированной гранулярностью и не выводится.
const (
	LayerSample Layer = "sample"
	LayerRaw    Layer = "raw"
	LayerMinute Layer = "minute"
	LayerHour   Layer = "hour"
	LayerDay    Layer = "day"
)

// Ошибки разбора.
var (
	// ErrMalformed — тело не разбирается как JSON ожидаемой формы.
	ErrMalformed = errors.New("тело не разбирается")

	// ErrLayerUnknown — в доставке есть метрики, но определить их слой нечем:
	// плотных метрик нет, у автоматизации нет предыдущего надёжного слоя, а
	// заголовок ненадёжен. Точки не сохраняются, тело остаётся в архиве —
	// доставку подберёт пересборка, когда слой станет известен.
	//
	// Молчаливый выбор `raw` здесь недопустим: призрачный разрез поедет в
	// каталог и в правило Read API «самый мелкий слой, покрывающий диапазон».
	ErrLayerUnknown = errors.New("слой доставки не определяется")
)

// Порог плотности: метрика, у которой не меньше стольких точек с метками,
// классифицируется по собственному выравниванию. Редкая наследует слой
// доставки — её собственное выравнивание ничего не значит, потому что одна
// метка на часе бывает и у минутного ряда.
const denseThreshold = 10

// timeLayout — формат метки точки в секции metrics. Другого там не
// встречается: RFC 3339 живёт только в stateOfMind, а Unix-эпоха — внутри
// heartbeatSeries, и меткой точки не является.
const timeLayout = "2006-01-02 15:04:05 -0700"

// Point — одна разобранная точка.
type Point struct {
	// Metric — имя метрики как прислал HAE. Исключение — sleep_analysis: под
	// одним именем приезжают две несовместимые схемы, и они разводятся.
	Metric string
	Units  string
	Layer  Layer

	// Start и End — координаты точки в UTC. У точки-измерения End равен Start:
	// ключ одной формы для всех точек, потому что интервальная и точечная
	// формы не встречаются вперемешку внутри метрики одной доставки
	// (находка 47).
	Start time.Time
	End   time.Time

	// OffsetSeconds — смещение исходной зоны. Нормализовать время без него
	// значит потерять, в каком часовом поясе человек находился.
	OffsetSeconds int

	// Raw — содержимое точки исходными байтами, как пришло в теле. Пересборка
	// повторной сериализацией теряет литерал (`1.0` → `1`, целые больше 2^53
	// сдвигаются, невалидный UTF-8 → U+FFFD), и потеря не видна тестам на
	// фикстурах: они сравнивают разобранное с разобранным.
	Raw json.RawMessage

	// local — метка в исходной зоне. Наружу не отдаётся: хранится всегда UTC
	// плюс офсет. Нужна выводу слоя — HAE строит сетку по МЕСТНОМУ времени, и
	// выравнивание, посчитанное по UTC, объявляет часовую выгрузку минутной в
	// зонах с получасовым смещением (+0530, +0545, +0930).
	local time.Time
}

// Entity — сущность с собственным идентификатором: тренировка или запись
// секции вроде stateOfMind. От точки отличается тем, что её адресует сам `id`,
// а не координаты, и слоя у неё нет вовсе: подробности выгрузки у этих секций
// в интерфейсе HAE не бывает.
type Entity struct {
	// ID — идентификатор из HealthKit. Приходит из тела и ограничен по длине:
	// уезжает и в первичный ключ, и в записи лога.
	ID string
	// Kind — верхнеуровневый ключ секции HAE ДОСЛОВНО (`stateOfMind`, не
	// `state_of_mind`): инвариант «форма Apple не транслируется» относится и к
	// именам секций, а переименование после того, как значение легло в базу,
	// стоило бы миграции данных. У тренировки род один и в ключ не входит.
	Kind string
	// Name — имя тренировки как прислал HAE, локализованное («В помещении
	// Ходьба»). У записей пустое.
	Name string

	// Start и End — координаты в UTC. Конец, которого нет или который не
	// читается, равен началу: ключ сущности — `id`, схлопывать нечего, а истина
	// остаётся в Raw. У точки то же вырождение запрещено — там оно схлопнуло бы
	// две записи в одну координату.
	Start time.Time
	End   time.Time

	// OffsetSeconds — смещение зоны НАЧАЛА. Колонка одна, а тренировка через
	// смену зоны дала бы два разных.
	OffsetSeconds int

	// Duration — длительность тренировки в секундах, как прислал HAE. Не
	// вычисляется из интервала: HAE шлёт 91.746 при интервале в 91 секунду.
	// Отсутствие выражается nil, а не нулём: ноль — законная длительность.
	Duration *float64

	// Raw — содержимое сущности исходными байтами, как пришло в теле, включая
	// маршрут и внутренние ряды.
	Raw json.RawMessage
}

// Result — итог разбора доставки. Частичные исходы живут в счётчиках, а не в
// ошибке: пакет, у которого не разобралась одна точка из тысячи, — обычное
// дело, и терять из-за неё остальное нельзя.
type Result struct {
	Points []Point

	// Workouts и Records — сущности с собственным идентификатором. Разведены,
	// потому что у тренировки есть заголовок (имя, интервал, длительность), по
	// которому идёт выборка, а у записи его нет.
	Workouts []Entity
	Records  []Entity

	// SkippedNoID — сущности без пригодного идентификатора: пустого, нет вовсе
	// или длиннее предела. Один счётчик на все три случая: исход у них общий, а
	// различает их только тело, лежащее в архиве.
	SkippedNoID int
	// SkippedEntityNoTime — сущности с идентификатором, но без разбираемой
	// метки времени.
	SkippedEntityNoTime int
	// SkippedEntityMalformed — элементы секции, не разобравшиеся как объект.
	SkippedEntityMalformed int

	// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает,
	// отсортированные и без повторов. Половина живого потока состоит из таких
	// доставок целиком (48 из 99: workouts и stateOfMind), и без этого списка
	// они неотличимы от разобранной доставки с пустой секцией метрик.
	//
	// Список канонизирован потому, что уезжает в базу и сравнивается между
	// доставками, а порядок ключей в JSON от HAE нестабилен.
	Uncovered []string
	// UncoveredDropped — сколько имён отброшено границей списка. Молчаливое
	// усечение сделало бы список уверенным, но неполным ответом на вопрос «что
	// останется потерянным, если тело удалить».
	UncoveredDropped int

	// Metrics — сколько метрик встретилось в секции.
	Metrics int
	// SkippedNoTime — точки без разбираемой метки времени.
	SkippedNoTime int
	// SkippedMalformed — точки, не разобравшиеся как объект JSON.
	SkippedMalformed int
	// SkippedBadEnd — точки, у которых есть `end`, но он не разбирается.
	// Вырождать такую точку в мгновенную нельзя: она схлопнулась бы с соседней
	// по координате.
	SkippedBadEnd int

	// Categoricals — различные наблюдённые категориальные значения доставки с
	// выведенными кодами, в детерминированном порядке. Повтор одной строки в
	// тысяче точек даёт один элемент.
	Categoricals []Categorical
	// CategoricalUnknown — сколько РАЗЛИЧНЫХ наблюдений не получило кода.
	// Считаются различные значения, а не их вхождения: счётчик отвечает на
	// вопрос «сколько строк ждёт словаря», а не «сколько точек их несло».
	//
	// В установившемся режиме он ненулевой — словарь покрывает только фазы сна,
	// а контекст пульса и имена тренировок объявлены категориальными заранее.
	// Сигналом «появилось новое» служит поэтому новая строка реестра, а не
	// ненулевой счётчик.
	CategoricalUnknown int
	// CategoricalDropped — сколько ВХОЖДЕНИЙ отброшено границами: непомерная
	// длина значения или переполнение числа различных значений.
	//
	// Единица названа вслух и отличается от соседнего счётчика намеренно.
	// Считать здесь различные значения нечем: набор ограничен при вставке (иначе
	// накопитель растёт вместе с телом, а тело контролирует отправитель), и
	// отброшенный ключ нигде не запоминается — запомнить его значило бы вернуть
	// ровно тот неограниченный рост, ради устранения которого граница и стоит на
	// вставке. Поэтому счётчик отвечает на вопрос «сколько раз сработала
	// граница», а не «сколько строк потеряно»; на второй отвечает реестр в
	// витрине.
	CategoricalDropped int

	// Layer — слой, выведенный для доставки в целом (тот, что наследуют редкие
	// метрики). Пустой, если плотных метрик не было и наследовать было нечего.
	// Его сохраняет вызывающий, чтобы следующая доставка той же автоматизации
	// могла его унаследовать.
	Layer Layer
	// LayerMismatch — выведенный слой разошёлся с НАДЁЖНЫМ заголовком.
	// Заголовок `Default` в сравнении не участвует: он не означает режима, и
	// сравнение с ним давало бы WARN на каждой доставке потока в пять минут.
	LayerMismatch bool
	// HeaderLayer — слой по заголовку, если заголовок надёжен.
	HeaderLayer Layer
}

// Meta — что доставка рассказала о себе заголовками, плюс память о прошлых
// доставках той же автоматизации.
type Meta struct {
	// Aggregation — заголовок `automation-aggregation`. Надёжен только в
	// значениях `Minutes` и `Hours`.
	Aggregation string

	// FallbackLayer — последний надёжно выведенный слой этой же автоматизации
	// (`automation-id`). Нужен доставкам без плотных метрик: измерено 2 такие
	// из 89, обе с заголовком `Default`. Ищет и передаёт его вызывающий —
	// разбор остаётся чистой функцией.
	FallbackLayer Layer

	// Locale — нормализованный языковой тег доставки из `Accept-Language`
	// (находка 32). Сужает поиск по словарю категориальных значений и НИКУДА не
	// сохраняется: заголовков в сыром архиве нет, поэтому доставка,
	// восстановленная из осиротевшего тела, приезжает без локали — и обязана
	// дать то же состояние. Пустая локаль законна.
	Locale string
}

// Parse разбирает секцию metrics тела доставки в точки.
//
// Ошибка возвращается только когда точек не будет вовсе: тело не JSON
// (ErrMalformed) или слой не определяется (ErrLayerUnknown). Всё остальное —
// счётчики в Result. Отсутствие секции metrics ошибкой не является: доставки
// с одними тренировками или состоянием разума — норма.
func Parse(body []byte, meta Meta) (res Result, err error) {
	// Разбор чужого формата обязан отвечать ошибкой, а не паникой: приём не
	// имеет права упасть из-за того, что HAE прислал невиданное. Перехват
	// стоит здесь, внутри разбора, а не выше: паника из хранилища — настоящий
	// дефект, и глушить её нельзя.
	defer func() {
		if r := recover(); r != nil {
			res = Result{}
			err = fmt.Errorf("%w: паника разбора: %s", ErrMalformed, clip(fmt.Sprint(r)))
		}
	}()

	env, err := decodeEnvelope(body)
	if err != nil {
		return Result{}, err
	}
	res.Uncovered = env.uncovered
	res.UncoveredDropped = env.dropped

	cat := newCategoricals(meta.Locale)

	res.Workouts = decodeEntities(env.workouts, workoutsSection, &res)
	res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res)
	// Имя тренировки берётся из уже разобранного заголовка сущности. Мягкое
	// чтение превратило значение не того типа в пустую строку, а пустая строка
	// наблюдением не считается, — то есть «имени не было» и «имя приехало
	// числом» дают один исход, и он верный.
	for _, w := range res.Workouts {
		cat.add(workoutsSection, fieldName, w.Name)
	}

	metrics := env.metrics
	res.Metrics = len(metrics)
	if len(metrics) == 0 {
		res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
		return res, nil
	}

	groups := make([]group, 0, len(metrics))
	for _, m := range metrics {
		g := decodeGroup(m, &res, cat)
		if len(g.summaries) > 0 {
			groups = append(groups, group{
				metric: sleepSummaryMetric,
				units:  g.units,
				points: g.summaries,
				layer:  LayerDay,
				fixed:  true,
			})
			g.summaries = nil
		}
		if len(g.points) > 0 {
			groups = append(groups, g)
		}
	}
	if len(groups) == 0 {
		res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
		return res, nil
	}

	res.HeaderLayer = headerLayer(meta.Aggregation)
	if err := assignLayers(groups, meta, &res); err != nil {
		// Список непокрытых секций переживает отказ: доставка, у которой не
		// определился слой, обязана остаться записью о том, что в теле есть
		// невосстановимая секция. Иначе ретеншен увидит failed без списка и
		// решит, что терять нечего.
		//
		// Сущности при этом НЕ отдаются, хотя слоя у них нет и разобрались они
		// успешно. «Всё или ничего» относится к доставке, а не к точкам: отдай
		// мы их, доставка получила бы `failed` при частично записанной витрине,
		// и повторная свёртка перестала бы быть no-op. Цена названа в спеке —
		// такая доставка доедет пересборкой, а тело ждёт в архиве.
		return Result{
			Metrics:          res.Metrics,
			Uncovered:        res.Uncovered,
			UncoveredDropped: res.UncoveredDropped,
		}, err
	}

	// Наблюдения отдаются только на успешном исходе: «всё или ничего» относится
	// к доставке целиком. У отказа по слою (см. ветку выше) сущности не
	// отдаются по той же причине.
	res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()

	total := 0
	for _, g := range groups {
		total += len(g.points)
	}
	res.Points = make([]Point, 0, total)
	for _, g := range groups {
		for _, p := range g.points {
			p.Layer = g.layer
			res.Points = append(res.Points, p)
		}
	}
	return res, nil
}

// group — точки одной метрики одной доставки: единица, для которой выводится
// слой. Классификация именно по метрике, а не по доставке: при перенастройке
// автоматизации приезжают смешанные доставки, и отнесение такой доставки к
// одному слою складывает минутные точки с посекундными (находка 33).
type group struct {
	metric string
	units  string

	points []Point
	// layer — итоговый слой группы.
	layer Layer
	// fixed — слой назначен схемой, а не выведен (суточная сводка сна). Такие
	// группы не участвуют в определении слоя доставки: сводок бывает больше
	// порога плотности, и их полуночные метки назначили бы всей доставке hour.
	fixed bool
	// alignment — самое мелкое выравнивание среди меток группы.
	alignment Layer
	dense     bool

	// summaries — точки, схема которых опознана прямо при разборе и слой
	// которым назначен, а не выведен (суточная сводка сна). Держатся отдельно,
	// потому что в определении слоя доставки не участвуют: сводок бывает больше
	// порога плотности, и их полуночные метки назначили бы всей доставке `hour`.
	summaries []Point
}

// envelope — форма тела, ровно настолько подробная, насколько нужно разбору.
//
// Точки держатся сырыми сообщениями и декодируются по одной: разбор тела в
// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой
// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это
// OOM ровно на пике потока, когда терять доставки дороже всего.
// Секции, которые разбор покрывает. Прочие секции с собственными `id` (`ecg`,
// `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`)
// покрытыми намеренно не становятся: живой поток не приносил их ни разу, их
// форма никем не наблюдалась, а полнота покрытия HealthKit ради полноты целью
// проекта не является.
const (
	metricsSection     = "metrics"
	workoutsSection    = "workouts"
	stateOfMindSection = "stateOfMind"
)

// decodeCovered разбирает секцию, если разбор её покрывает; второй возврат
// говорит, взялся ли он за неё.
//
// Один источник и для разбора, и для перечисления непокрытых: перечисляющий
// спрашивает ровно того, кто разбирает, поэтому состояние «секция разбирается,
// но числится непокрытой» невыразимо по построению. Отдельный предикат
// `covered` разошёлся бы с этим switch при первой же новой секции.
//
// Повтор ключа покрытой секции JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не
// побеждает последняя: терять данные молча нельзя.
func decodeCovered(name string, dec *json.Decoder, env *envelope) (bool, error) {
	switch name {
	case metricsSection:
		var part []metricEnvelope
		if err := dec.Decode(&part); err != nil {
			return true, err
		}
		env.metrics = append(env.metrics, part...)
		return true, nil
	case workoutsSection:
		part, err := decodeSection(dec)
		if err != nil {
			return true, err
		}
		env.workouts = append(env.workouts, part...)
		return true, nil
	case stateOfMindSection:
		part, err := decodeSection(dec)
		if err != nil {
			return true, err
		}
		env.stateOfMind = append(env.stateOfMind, part...)
		return true, nil
	default:
		return false, nil
	}
}

// Границы на список непокрытых ключей. Тело контролирует отправитель целиком:
// без границ сто тысяч однобуквенных ключей превращаются в одну строку в базе
// и одну строку в логе того же порядка. Секций у HAE восемь, самое длинное имя
// — heartRateNotifications (22 байта), так что запас велик.
const (
	maxUncovered    = 32
	maxUncoveredLen = 64
)

type metricEnvelope struct {
	Name  string            `json:"name"`
	Units string            `json:"units"`
	Data  []json.RawMessage `json:"data"`
}

// pointHead — поля точки, нужные разбору. Всё остальное остаётся в Raw и
// хранится дословно: «служебных» полей у точки нет, отбрасывать нечего.
type pointHead struct {
	Date  string `json:"date"`
	Start string `json:"start"`
	End   string `json:"end"`

	// TotalSleep различает две схемы под именем sleep_analysis: поэпизодную и
	// суточную сводку. Общих полей, кроме date и source, у них нет.
	TotalSleep *json.RawMessage `json:"totalSleep"`

	// Value и Context — категориальные поля точки (фаза сна и контекст пульса,
	// находка 37). Сырыми сообщениями, а не строками: объяви их `string`, и
	// точка, у которой поле пришло числом, перестала бы разбираться вовсе —
	// json.Unmarshal отвечает ошибкой на несовпадение типа, а decodeGroup
	// считает такую точку не разобравшейся. Новый путь потери точки ради
	// удобства структуры недопустим.
	Value   *json.RawMessage `json:"value"`
	Context *json.RawMessage `json:"context"`
}

// decodeEnvelope разбирает конверт: отдаёт секцию metrics и имена секций,
// которых разбор не покрывает.
//
// Идёт по верхнему уровню одним декодером: Token() читает рамку объекта и имена
// членов, Decode() — значения. Значение покрытого ключа декодируется на месте,
// значение непокрытого ПРОГЛАТЫВАЕТСЯ декодированием в выбрасываемый
// RawMessage. Это форма из ExampleDecoder_Decode_stream стандартной библиотеки;
// в encoding/json/v2 та же операция названа прямо — SkipValue.
//
// Пропуск ручным счётом глубины по Token() выглядит дешевле и измеримо хуже:
// делимитеры идут мимо сканера, поэтому ограничитель вложенности encoding/json
// не работает, а стек токенов растёт как O(глубины). Тело 40 МиБ из вложенных
// скобок даёт пик 488 МиБ вместо контрактных четырёх тел — Decode отвергает его
// мгновенно. Разбор `data` в map[string]json.RawMessage дешевле по коду, но
// копирует байты ВСЕХ секций и держит их до конца разбора; у проглатывания
// копия одна и живёт до следующего члена.
func decodeEnvelope(body []byte) (envelope, error) {
	var env envelope

	fail := func(e error) (envelope, error) {
		return envelope{}, fmt.Errorf("%w: %s", ErrMalformed, ClipCause(e))
	}

	dec := json.NewDecoder(bytes.NewReader(body))

	// Верхний уровень тела: интересует только data. Прочие ключи конверта в
	// список не идут — иначе в одном списке смешались бы имена секций и мусор
	// конверта, а форму `{"data": …}` проверяет приём.
	at := dec.InputOffset()
	tok, err := dec.Token()
	if err != nil {
		return fail(err)
	}
	// Голый null телом ошибкой не был и не становится: прежний разбор
	// раскладывал его в пустую структуру. Границы поведения этой задачей не
	// двигаются — она добавляет список, а не строгость.
	if tok == nil {
		return envelope{}, nil
	}
	if d, ok := tok.(json.Delim); !ok || d != '{' {
		return fail(fmt.Errorf("ожидался объект, встречено %s", tokenDesc(tok, at)))
	}
	seen := make(map[string]struct{})
	for dec.More() {
		name, err := memberName(dec)
		if err != nil {
			return fail(err)
		}
		if name != "data" {
			if err := swallow(dec); err != nil {
				return fail(err)
			}
			continue
		}
		// Повтор самого члена `data` JSON допускает, и результаты
		// НАКАПЛИВАЮТСЯ, а не замещаются: присваивание теряло бы секции первого
		// члена целиком, причём молча — их имена уже отмечены в `seen` и во
		// второй список непокрытых не попали бы. Правило то же, что уровнем
		// ниже для повтора ключа секции.
		if err := decodeData(dec, seen, &env); err != nil {
			return fail(err)
		}
	}
	if err := expectDelim(dec, '}'); err != nil {
		return fail(err)
	}

	// Список канонизируется: порядок ключей в JSON от HAE нестабилен, а
	// значение уезжает в базу и сравнивается между доставками.
	sort.Strings(env.uncovered)
	return env, nil
}

// envelope — что разбор вынул из тела: покрытые секции и имена непокрытых.
type envelope struct {
	metrics     []metricEnvelope
	workouts    []json.RawMessage
	stateOfMind []json.RawMessage
	uncovered   []string
	dropped     int
}

// decodeData разбирает объект data, дописывая в конверт покрытые секции и
// имена непокрытых.
func decodeData(dec *json.Decoder, seen map[string]struct{}, env *envelope) error {
	at := dec.InputOffset()
	tok, err := dec.Token()
	if err != nil {
		return err
	}
	// data не объект — прежнее поведение: ошибка ровно там, где была.
	if d, ok := tok.(json.Delim); !ok || d != '{' {
		return fmt.Errorf("data: ожидался объект, встречено %s", tokenDesc(tok, at))
	}

	for dec.More() {
		name, err := memberName(dec)
		if err != nil {
			return err
		}

		handled, err := decodeCovered(name, dec, env)
		if err != nil {
			return err
		}
		if handled {
			continue
		}

		if err := swallow(dec); err != nil {
			return err
		}
		if _, dup := seen[name]; dup {
			continue
		}
		seen[name] = struct{}{}
		if len(env.uncovered) >= maxUncovered {
			env.dropped++
			continue
		}
		env.uncovered = append(env.uncovered, clipSection(name))
	}
	if _, err := dec.Token(); err != nil { // закрывающая скобка data
		return err
	}
	return nil
}

// decodeSection читает секцию сущностей элементами исходных байтов.
//
// Разбор до `json.RawMessage`, а не до структуры: сущность хранится дословно, и
// декодирование в типизированное значение потеряло бы литерал — ровно то, от
// чего защищает `Point.Raw`.
func decodeSection(dec *json.Decoder) ([]json.RawMessage, error) {
	var part []json.RawMessage
	if err := dec.Decode(&part); err != nil {
		return nil, err
	}
	return part, nil
}

// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
// escape-последовательностей, поэтому границы считаются по декодированному.
func memberName(dec *json.Decoder) (string, error) {
	at := dec.InputOffset()
	tok, err := dec.Token()
	if err != nil {
		return "", err
	}
	name, ok := tok.(string)
	if !ok {
		return "", fmt.Errorf("ожидалось имя члена, встречено %s", tokenDesc(tok, at))
	}
	return name, nil
}

// maxCauseLen — предел длины чужой причины в тексте нашей ошибки.
//
// Сообщения самого разбора значений не несут (см. tokenDesc), но ошибка может
// прийти и из encoding/json, а его UnmarshalTypeError кладёт в текст ЛИТЕРАЛ
// значения: тело из миллиона цифр давало текст ошибки в мегабайт, и он уезжал
// атрибутом `error` выше DEBUG. Предел держится здесь, на границе, а не у
// логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
const maxCauseLen = 200

// ClipCause переводит чужую ошибку в ограниченную по длине строку.
//
// Экспортировано ради приёма: он проверяет форму конверта тем же
// encoding/json и обязан держать тот же предел — иначе инвариант обходится
// через соседний пакет.
func ClipCause(err error) string {
	if err == nil {
		return ""
	}
	return clip(err.Error())
}

func clip(s string) string {
	if len(s) <= maxCauseLen {
		return s
	}
	// По границе рун: обрезка посреди многобайтовой руны даёт мусор в логе.
	cut := maxCauseLen
	for cut > 0 && !utf8.RuneStart(s[cut]) {
		cut--
	}
	return s[:cut] + "…"
}

// tokenDesc описывает встреченный токен БЕЗ его значения: род и смещение начала
// во входе.
//
// Значение из тела в сообщение не попадает никогда. Инвариант «тела запросов
// только на DEBUG и с обрезкой» обходится одним `%v`: строка в 8 МиБ на месте
// ожидаемого объекта давала текст ошибки в 8 МиБ, и он уезжал атрибутом `error`
// на уровень WARN — то есть содержимое доставки оказывалось в логе целиком.
// Предел держит само сообщение, а не обрезка на стороне логирующего: обрезка
// живёт в другом месте и о новой ошибке разбора не узнает.
//
// Род называется словарём JSON, а не именем типа языка: `json.Delim` не говорит
// ничего о том, какая скобка встретилась. Сам делимитер печатается значением —
// он из фиксированного набора и содержимого не раскрывает.
//
// Смещение берётся ДО чтения токена: InputOffset() отдаёт позицию конца
// последнего возвращённого токена, и снятое после оно указывало бы на конец
// виновного значения — то есть на восемь мегабайт дальше начала проблемы.
func tokenDesc(tok json.Token, at int64) string {
	kind := "?"
	switch v := tok.(type) {
	case json.Delim:
		kind = fmt.Sprintf("%q", string(v))
	case string:
		kind = "string"
	case json.Number, float64:
		kind = "number"
	case bool:
		kind = "bool"
	case nil:
		kind = "null"
	}
	return fmt.Sprintf("%s на смещении %d", kind, at)
}

// swallow проглатывает значение целиком, ничего не удерживая.
func swallow(dec *json.Decoder) error {
	var skip json.RawMessage
	return dec.Decode(&skip)
}

func expectDelim(dec *json.Decoder, want json.Delim) error {
	at := dec.InputOffset()
	tok, err := dec.Token()
	if err != nil {
		return err
	}
	if d, ok := tok.(json.Delim); !ok || d != want {
		return fmt.Errorf("ожидалось %q, встречено %s", want, tokenDesc(tok, at))
	}
	return nil
}

// clipSection обрезает слишком длинное имя по границе рун и помечает обрезку.
// Маркер приписывается СВЕРХ предела: обрезка не инъективна, и обрезанное имя
// сравнению со словарём известных секций не подлежит.
func clipSection(name string) string {
	if len(name) <= maxUncoveredLen {
		return name
	}
	cut := maxUncoveredLen
	for cut > 0 && !utf8.RuneStart(name[cut]) {
		cut--
	}
	return name[:cut] + "…"
}

// decodeGroup разбирает точки одной метрики. Точка без разбираемой метки
// пропускается со счётчиком — ронять из-за неё остальную доставку незачем.
//
// Схема точки определяется ЗДЕСЬ ЖЕ, в том же проходе, где точка разобрана.
// Второй проход по исходному массиву был бы неверен: список разобранных точек
// уже отфильтрован пропусками, и любой пропуск сдвигал бы соответствие — эпизод
// сна уезжал бы под имя суточной сводки, а сводка под имя эпизода. Индексной
// корреляции между двумя списками здесь не существует по построению.
func decodeGroup(m metricEnvelope, res *Result, cat *categoricals) group {
	g := group{metric: m.Name, units: m.Units, points: make([]Point, 0, len(m.Data))}

	for _, raw := range m.Data {
		var head pointHead
		if err := json.Unmarshal(raw, &head); err != nil {
			res.SkippedMalformed++
			continue
		}

		start, ok := parseTime(head.Date, head.Start)
		if !ok {
			res.SkippedNoTime++
			continue
		}

		end := start
		if head.End != "" {
			e, err := time.Parse(timeLayout, head.End)
			if err != nil {
				// Интервал, конец которого не читается, вырождать в точку
				// нельзя: две записи с общим началом получили бы одну
				// координату и одна из них исчезла бы. Пропускаем со
				// счётчиком — тело остаётся в архиве.
				res.SkippedBadEnd++
				continue
			}
			end = e
		}

		_, offset := start.Zone()
		p := Point{
			Metric:        m.Name,
			Units:         m.Units,
			Start:         start.UTC(),
			End:           end.UTC(),
			OffsetSeconds: offset,
			Raw:           raw,
			local:         start,
		}

		// Под именем sleep_analysis HAE шлёт две несовместимые схемы:
		// поэпизодную (start/end/value/qty) и суточную сводку
		// (totalSleep/core/rem/deep/awake с меткой на местной полуночи). Имя
		// sleep_analysis_summary — наше; инвариант «форма Apple не
		// транслируется» это не нарушает: поля внутри точки не
		// переименовываются, разделяются только имена метрик, под которыми
		// HAE смешал две схемы.
		if m.Name == sleepMetric && head.TotalSleep != nil {
			p.Metric = sleepSummaryMetric
			g.summaries = append(g.summaries, p)
			// Наблюдение снимается по ИТОГОВОМУ имени метрики, тому же, которым
			// адресуется единица хранения. У суточной сводки категориальных
			// полей нет, так что здесь это ноль работы, — но правило записано
			// один раз и не разойдётся при следующем разделении схем.
			cat.addPoint(p.Metric, &head)
			continue
		}
		cat.addPoint(p.Metric, &head)
		g.points = append(g.points, p)
	}

	g.dense = len(g.points) >= denseThreshold
	g.alignment = finestAlignment(g.points)
	return g
}

// Имена метрик сна: пришедшее от HAE и наше для суточной сводки.
const (
	sleepMetric        = "sleep_analysis"
	sleepSummaryMetric = "sleep_analysis_summary"
)

// parseTime разбирает метку точки. Начало берётся из start, а при его
// отсутствии — из date. Измерено: start, когда он есть, всегда совпадает с
// date, поэтому правило не вводит второго источника метки — оно закрывает
// случай, когда HAE перестанет их дублировать.
func parseTime(date, start string) (time.Time, bool) {
	s := start
	if s == "" {
		s = date
	}
	if s == "" {
		return time.Time{}, false
	}

	t, err := time.Parse(timeLayout, s)
	if err != nil {
		return time.Time{}, false
	}
	return t, true
}

// finestAlignment возвращает самое мелкое выравнивание среди меток.
//
// Именно самое мелкое, а не преобладающее: у плотных метрик выравнивания
// перемешаны (active_energy — 1320 минутных меток и 21 часовая), потому что
// метка ровно на часе одновременно является и минутной. Метрика, у которой
// хоть одна метка стоит на середине часа, часовой не является.
func finestAlignment(points []Point) Layer {
	finest := LayerHour
	for _, p := range points {
		// По МЕСТНОЙ метке, а не по UTC: HAE строит сетку в зоне телефона.
		// В зонах с получасовым смещением (+0530 Индия, +0545 Непал, +0930
		// Аделаида) ровный местный час превращается в UTC-метку на половине,
		// и вся часовая выгрузка уехала бы в слой `minute` — а там столкнулась
		// бы с настоящей минутной автоматизацией на координате hh:30 и завысила
		// сумму минутного слоя вдвое.
		switch {
		case p.local.Second() != 0 || p.local.Nanosecond() != 0:
			return LayerRaw
		case p.local.Minute() != 0:
			finest = LayerMinute
		}
	}
	return finest
}

// headerLayer переводит заголовок в слой, но только когда заголовок надёжен.
// `Default` соответствует трём разным режимам выгрузки и не означает ничего.
func headerLayer(aggregation string) Layer {
	switch aggregation {
	case "Minutes":
		return LayerMinute
	case "Hours":
		return LayerHour
	default:
		return ""
	}
}

// finer возвращает более мелкий из двух слоёв.
func finer(a, b Layer) Layer {
	if Rank(a) < Rank(b) {
		return a
	}
	return b
}

// Layers — ЕДИНСТВЕННЫЙ словарь слоёв, упорядоченный от самого мелкого к самому
// крупному.
//
// Один, потому что иначе их становится четыре: порядок для разбора, перечень
// для выборки охватов, проверка параметра запроса и текст отказа клиенту. Ни
// компилятор, ни тест их не сверяют — новая константа `Layer` скомпилировалась
// бы, получила бы ранг «крупнее всех», не попала бы в выборку охватов и
// отвергалась бы маршрутом как незнакомая. Симптомом был бы пустой ряд при
// непустых данных. Случай не гипотетический: слой `sample` наполнится импортом
// родного экспорта Apple.
//
// Срез, а не карта: порядок здесь и есть содержание.
var Layers = []Layer{LayerSample, LayerRaw, LayerMinute, LayerHour, LayerDay}

// Known отвечает, знаком ли слой.
func Known(l Layer) bool { return Rank(l) < len(Layers) }

// Rank — место слоя в порядке от мелкого к крупному. Незнакомый слой считается
// крупнее любого известного.
func Rank(l Layer) int {
	for i, known := range Layers {
		if known == l {
			return i
		}
	}
	return len(Layers)
}
