// Package catalog — каталог разрезов и измеренный род агрегации.
//
// Отвечает на два вопроса потребителя: «что у тебя вообще есть» (метрики,
// единицы, слои с границами и числом точек) и «какая свёртка по этой метрике
// осмысленна» (род агрегации). Оба ответа производны от витрины и ничего в неё
// не пишут.
//
// Род **измеряется**, а не размечается. Форма точки его не выдаёт: `Avg`/`Min`/
// `Max` есть только у `heart_rate`, заведомо мгновенные `walking_speed` и
// `blood_oxygen_saturation` приходят в `qty` ровно так же, как шаги (находка
// 40); заголовок доставки про род молчит; единицы дают процентов девяносто и
// ломаются на краях. Зато витрина содержит собственную сверку: одна метрика
// лежит в минутном и часовом разрезе одновременно, и часовое значение либо
// равно сумме минутных, либо их среднему.
//
// Ни один известный проект род не измеряет — HealthKit зашивает его в тип
// метрики, Home Assistant получает `state_class` от интеграции, Graphite
// выводит регуляркой по имени, Prometheus принимает от отправителя. У всех у
// них есть привилегия, которой нет у нас: поставщик объявляет тип на входе.
package catalog

import (
	"context"
	"log/slog"
	"math"
	"sort"
	"time"

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

// Style — род агрегации метрики.
//
// Нулевое значение — `unknown`, и это не заглушка: «род не измерен» и есть
// честное состояние по умолчанию, а забытое присваивание не имеет права
// выглядеть как измеренный род.
type Style int

// Роды агрегации. Их два, а не четыре, и это следствие измерения, а не
// упрощения. HealthKit различает `cumulative`, `discreteArithmetic`,
// `discreteTemporallyWeighted` (пульс) и `discreteEquivalentContinuousLevel`
// (аудиоэкспозиция) — но часовой слой HAE считается арифметически, а не по
// Apple: у `environmental_audio_exposure`, которую Apple усредняет
// логарифмически, часовое значение сошлось с обычным арифметическим средним
// минутных в 59 часах из 62. Стили, которые в наших данных ничем не
// проявляются, можно было бы только разметить руками — то есть вернуться к
// тому, от чего уходит вся конструкция.
const (
	Unknown Style = iota
	Cumulative
	Instant
)

func (s Style) String() string {
	switch s {
	case Cumulative:
		return "cumulative"
	case Instant:
		return "instant"
	default:
		return "unknown"
	}
}

// Собственной сериализации у Style нет намеренно: строку в ответ кладёт
// транспорт (`internal/httpapi`, форма провода). Домен владеет ЗНАЧЕНИЯМИ
// словаря, а не их видом на проводе; `String()` при этом нужен и логам, и
// сообщениям тестов, и проводу — второго словаря заводить незачем.

// Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова,
// потому что от них зависят счётчики основания в ответе.
const (
	// Window — сколько самых свежих общих часов метрики участвует в сверке.
	// Ограничивает стоимость: полный обход рос бы вместе с журналом, а окно
	// держит работу в пределах 2×48 объектов на метрику. Измерено, что на живом
	// корпусе окно даёт те же вердикты, что и полный обход.
	Window = 48

	// MinAgreeing — сколько согласных часов нужно, чтобы объявить род. Один
	// совпавший час остаётся свидетельством одного часа, а на этом роде потом
	// суммируют год. Цена порога измерена: он уводит в `unknown` ровно одну
	// метрику корпуса, у которой согласный час был единственным.
	MinAgreeing = 3

	// coarsePoints и minFinePoints — структурные условия пригодности часа. Они
	// же уходят в хранилище предварительным отбором: содержимое заведомо
	// непригодного часа разжимать незачем.
	coarsePoints  = 1
	minFinePoints = 2

	// horizonSlack — насколько метка часа может опережать текущее время и всё
	// ещё считаться свидетельством.
	//
	// Час объекта берётся из метки в теле доставки, а тело мы не контролируем:
	// без верхней границы одна доставка с метками в будущем занимает окно
	// целиком и подменяет измеренный род метрики (построено и прогнано:
	// мгновенная метрика объявлялась накопительной при нуле противоречащих
	// часов). Запас — на расхождение часов телефона и сервера; данные из
	// будущего сверх него свидетельством не являются.
	horizonSlack = time.Hour

	// maxUnitsReported — сколько различных единиц метрики попадает в ответ.
	//
	// Единицы приходят из тела дословно и ничем не ограничены, а число
	// различных значений равно числу объектов метрики: сотня доставок с разными
	// строками единиц раздувает одну запись каталога на десятки килобайт.
	// Событие невозможное по наблюдениям (единицы не менялись ни разу) и
	// поэтому не имеющее естественного потолка — потолок ставится здесь.
	maxUnitsReported = 8

	// maxMetricInLog — предел длины имени метрики в записи лога. Тот же предел и
	// та же причина, что у координат столкновения в хранилище: имя приходит из
	// тела дословно при пределе приёма в 64 МиБ, а запись повторяется на каждый
	// запрос каталога.
	maxMetricInLog = 64

	// tolerance — относительный допуск ВСЕХ сравнений измерения.
	//
	// Величина названа числом, потому что от неё зависят счётчики основания:
	// вердикты метрик на живом корпусе одинаковы при допуске от 1e-9 до 1e-3, а
	// число согласных часов у `heart_rate` при этом меняется с 29 на 49.
	// Взято строгое: канонизация содержимого округляет числа до 12 значащих
	// цифр, значит всё крупнее 1e-12 представлением не объясняется; 1e-9
	// оставляет три порядка запаса и остаётся на шесть порядков строже любого
	// содержательного расхождения — сумма и среднее при n ≥ 2 различаются не
	// меньше чем вдвое.
	tolerance = 1e-9
)

// closeEnough — ЕДИНСТВЕННЫЙ предикат сравнения чисел в измерении.
//
// Один на все три сравнения намеренно. Напиши «различимость суммы и среднего»
// точным неравенством, а «сходимость с гипотезой» — с допуском, и появится час,
// подтверждающий обе гипотезы сразу; его исход молча определил бы порядок веток
// `if`. При одном предикате такой час невыразим.
//
// Допуск относительный, абсолютного порога нет намеренно: второй константы,
// которую пришлось бы объяснять, задача не заводит. Цена названа вслух: при
// обоих нулях предикат истинен, и от нулевого часа защищает не он, а проверка
// различимости в verdictOf. Полагаться здесь на «около нуля не сходится»
// нельзя — ровно наоборот.
func closeEnough(a, b float64) bool {
	if a == b {
		return true
	}
	return math.Abs(a-b)/math.Max(math.Abs(a), math.Abs(b)) <= tolerance
}

// ClipMetric обрезает имя метрики для записи лога.
//
// Экспортирована потому, что предел один на всех, кто пишет имя метрики в лог:
// имя приходит из тела дословно при пределе приёма в 64 МиБ, а запись
// повторяется на каждый запрос. Второй предел разошёлся бы с первым молча.
func ClipMetric(metric string) string {
	if len(metric) <= maxMetricInLog {
		return metric
	}
	return metric[:maxMetricInLog] + "…"
}

// ФОРМЫ ПРОВОДА В ЭТОМ ПАКЕТЕ НЕТ, и это решение, а не упущение.
//
// Типы ниже — форма ответа use-case, а не форма ответа HTTP: `json`-тегов они
// не несут и до сериализации не доезжают. Публичный контракт чтения объявляет
// транспорт (`internal/httpapi`), поэтому переименование поля здесь байты
// ответа клиенту не меняет — оно ломает компиляцию перевода. Обратная цена
// названа вслух: новое поле само в ответ не попадёт, его обязан перечислить
// транспорт. Решение и цена обеих сторон — `docs/architecture.md`, раздел
// «Read API», подраздел «Форма провода».

// Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их
// разности были осмысленны: `Hours − Compared` — часы, отброшенные проверкой
// пригодности, `Compared − Agreeing − Conflicting` — часы, не сошедшиеся ни с
// одной гипотезой.
//
// Одного числа не хватало: «часов было 48, а пригодным не оказалось ни одного»
// и «часов не было вовсе» — разные события, и клиент обязан различать их без
// второго запроса.
type Basis struct {
	Hours       int
	Compared    int
	Agreeing    int
	Conflicting int
	FirstHour   *time.Time
	LastHour    *time.Time
}

// Aggregation — род вместе с основанием.
//
// Встраивание здесь — удобство домена, а не форма ответа: плоскость объекта
// `aggregation` на проводе объявлена транспортом поимённо и от этого
// встраивания не зависит.
type Aggregation struct {
	Style Style
	Basis
}

// LayerRange — разрез метрики в ответе каталога.
//
// Границы — метки первой и последней точки слоя, включительно, и это границы
// ДАННЫХ, а не обещание покрытия: внутри диапазона законно есть дыры. Числа
// часовых объектов здесь нет: объект — деталь хранения, клиент про него не
// знает.
type LayerRange struct {
	Layer  string
	From   time.Time
	To     time.Time
	Points int
}

// Metric — запись каталога.
type Metric struct {
	Metric string
	// Units — множество различных единиц метрики, отсортированное. Массив, а не
	// строка: на живом потоке единицы не менялись ни разу, но одна форма поля
	// для обоих случаев честнее строки, которая при расхождении молча выберет
	// одно из двух.
	Units       []string
	Aggregation Aggregation
	Layers      []LayerRange
}

// Snapshot — каталог вместе с версией ответа.
//
// Версия пустая, когда подписать ответ нечем: витрина изменилась, пока он
// собирался, или прочитать её версию не удалось. Это не отказ — ответ уходит
// целиком, просто без условной метки, ровно как до появления условного запроса.
//
// Имя перекликается с `store.CatalogSnapshot` намеренно и означает другое: тот
// снимок — вход измерения (объекты и разрезы), этот — готовый ответ.
type Snapshot struct {
	Version string
	Metrics []Metric
}

// Version — версия ОТВЕТА каталога: версия витрины плюс горизонт измерения.
//
// Горизонт входит в неё, потому что ответ есть функция обоих. Час объекта
// сравнивается с `Now() + horizonSlack`, и с ходом часов состав окна меняется
// без единого коммита: метка из будущего, лежащая в витрине (сбитые часы
// телефона — состояние, о котором рядом пишется WARN), въезжает в окно сама и
// способна перевернуть измеренный род. Построено и прогнано: та же версия
// витрины, `cumulative` против `unknown`, ноль коммитов между.
//
// Огрубление до часа не приблизительное, а точное: `hour_utc` объектов лежит
// ровно на часах, поэтому отбор `hour_utc <= горизонт` меняется ровно при
// переходе горизонта через час. Цена — один полный ответ в час на потребителя
// при неизменившейся витрине; сборка каталога на живом корпусе стоит 45 мс.
func (s *Service) Version(ctx context.Context) (string, error) {
	version, err := s.store.StateVersion(ctx)
	if err != nil {
		// Отказ пробы отказом маршрута не является — но и молчать о нём нельзя:
		// без метки условный запрос выключается для всех потребителей, а
		// снаружи это неотличимо от нормы. DEBUG, потому что адресат здесь
		// разработчик: владельцу об этом скажет `/stats`, когда появится.
		s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err)
		return "", err
	}
	return Stamp(version, Horizon()), nil
}

// Horizon — верхняя граница окна измерения на текущий момент.
//
// Экспортирована потому, что горизонт нужен ВСЕМ, кто объявляет измеренный род:
// маршрут точек снимает род и метку с одного горизонта, иначе метка подтвердит
// неизменность ответа, в котором род уже перевернулся ходом часов.
func Horizon() time.Time { return store.Now().Add(horizonSlack) }

// MeasureWindow — окно измерения рода для заданного горизонта.
//
// Одно на всех потребителей измерения. Второй экземпляр параметров разошёлся бы
// с первым молча, а вердикт зависит от каждого из них: слои сверки, размер окна
// и оба структурных порога уходят в предварительный отбор хранилища.
func MeasureWindow(horizon time.Time) store.CatalogWindow {
	return store.CatalogWindow{
		Fine:          string(hae.LayerMinute),
		Coarse:        string(hae.LayerHour),
		Hours:         Window,
		Horizon:       horizon,
		CoarsePoints:  coarsePoints,
		MinFinePoints: minFinePoints,
	}
}

// Stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой:
// подписывать нечем — значит нечем, и горизонт этого не меняет.
//
// Экспортирована по той же причине, что и Horizon: правило «метка строится из
// всего, от чего зависит ответ» держится ровно до тех пор, пока склейка одна.
func Stamp(version string, horizon time.Time) string {
	if version == "" {
		return ""
	}
	return version + "." + horizon.Truncate(time.Hour).Format("2006010215")
}

// Service собирает каталог по витрине.
type Service struct {
	store *store.Store
	log   *slog.Logger
}

// New собирает сервис каталога.
func New(st *store.Store, log *slog.Logger) *Service {
	return &Service{store: st, log: log}
}

// Metrics отдаёт каталог: разрезы всех метрик и измеренный род каждой.
//
// Род нигде не хранится и считается заново на каждый запрос. Хранимое значение
// было бы вторым производным состоянием рядом с витриной — его пришлось бы
// пересчитывать после каждой свёртки, переносить или не переносить пересборкой
// и объяснять, на каком составе данных оно снято; устаревшее при этом выглядит
// ровно как свежее. Вычисленный на запрос род есть функция витрины, а витрина —
// функция журнала, и устаревать в нём нечему.
// Ответ подписывается версией витрины: она нужна условному запросу, и снимает
// её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён:
// версия, снятая после чтения, пометила бы устаревший снимок свежей меткой.
func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
	horizon := Horizon()

	var snap store.CatalogSnapshot
	version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
		var err error
		snap, err = s.store.ReadCatalog(ctx, MeasureWindow(horizon))
		return err
	})
	if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
		// Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в
		// ответ и второй раз её не пишет.
		//
		// Отмена снаружи и занятость базы означают «не сделано», а не «не
		// выходит»: клиент, оборвавший запрос по своему тайм-ауту, не должен
		// давать владельцу ERROR — иначе единственный канал, по которому видно
		// настоящий сбой хранилища, забивается штатными событиями. Правило и его
		// определение живут в store и читаются уже третьим местом.
		if store.Transient(err) {
			s.log.DebugContext(ctx, "catalog interrupted", "capability", "query", "error", err)
		} else {
			s.log.ErrorContext(ctx, "catalog failed", "capability", "query", "error", err)
		}
		return Snapshot{}, err
	}

	out := make([]Metric, 0, len(snap.Layers))
	for _, group := range groupLayers(snap.Layers) {
		style, basis := Measure(snap.Pairs[group.metric])
		// Единственный чекпоинт этой границы: род — свойство, на котором Read
		// API строит арифметику года, и его смена не имеет права проходить
		// молча. Уровень WARN, потому что адресат — владелец, а лечится это
		// настройкой автоматизаций HAE, а не кодом. Значений точек в записи нет:
		// данные о здоровье чувствительнее токенов. Имя метрики обрезано — оно
		// приходит из тела дословно, а запись повторяется на каждый запрос.
		if basis.Conflicting > 0 {
			s.log.WarnContext(ctx, "aggregation style conflict",
				"capability", "query",
				"metric", ClipMetric(group.metric),
				"hours", basis.Hours,
				"compared", basis.Compared,
				"agreeing", basis.Agreeing,
				"conflicting", basis.Conflicting)
		}
		// Данные, помеченные будущим, в измерение не попадают вовсе — но молчать
		// о них нельзя: это либо сбитые часы телефона, либо чужое тело в приёме,
		// и оба случая лечатся не кодом. Видно это по разрезам, а не по окну:
		// окно такие часы уже отбросило.
		if to := group.latest(); to.After(horizon) {
			s.log.WarnContext(ctx, "future data",
				"capability", "query",
				"metric", ClipMetric(group.metric),
				"last_ts", store.FormatTime(to),
				"horizon", store.FormatTime(horizon))
		}
		out = append(out, Metric{
			Metric:      group.metric,
			Units:       group.units,
			Aggregation: Aggregation{Style: style, Basis: basis},
			Layers:      group.layers,
		})
	}
	if version == "" {
		// Витрина изменилась, пока ответ собирался (или версию не прочитать).
		// Ответ уйдёт без метки — это безопасная сторона, но след нужен: под
		// плотным потоком доставок так может уходить каждый ответ, и тогда
		// механизм не окупается вовсе.
		s.log.DebugContext(ctx, "catalog unsigned", "capability", "query")
	}
	return Snapshot{Version: Stamp(version, horizon), Metrics: out}, nil
}

type metricGroup struct {
	metric  string
	units   []string
	layers  []LayerRange
	unitSet map[string]bool
	byLayer map[string]int
}

// latest — самая поздняя метка данных метрики по всем её слоям.
func (g metricGroup) latest() time.Time {
	var out time.Time
	for _, l := range g.layers {
		if l.To.After(out) {
			out = l.To
		}
	}
	return out
}

// groupLayers схлопывает строки выборки в записи каталога.
//
// Схлопывание поручено явно, потому что выборка группируется ВМЕСТЕ с
// единицами: у метрики, чьи объекты разошлись единицами, на один слой придут две
// строки. Оставь это на самотёк — клиент получит два элемента с одинаковым
// `layer` ровно в тот единственный день, ради которого единицы и сделаны
// множеством. Различие при этом не теряется: оно видно множеством единиц
// метрики.
func groupLayers(rows []store.LayerRange) []metricGroup {
	var out []metricGroup
	var cur *metricGroup

	for _, r := range rows {
		// Указатель, а не сравнение имени с пустой строкой: пустое имя — законное
		// значение колонки, и сентинел выбрасывал бы такую метрику из каталога
		// молча. Каталог отвечает на вопрос «что у тебя вообще есть»; терять на
		// нём то, что в витрине лежит, нельзя.
		if cur == nil || r.Metric != cur.metric {
			if cur != nil {
				out = append(out, *cur)
			}
			cur = &metricGroup{metric: r.Metric, byLayer: map[string]int{}, unitSet: map[string]bool{}}
		}
		if r.Units != "" {
			cur.unitSet[r.Units] = true
		}
		i, ok := cur.byLayer[r.Layer]
		if !ok {
			cur.layers = append(cur.layers, LayerRange{
				Layer: r.Layer, From: r.From, To: r.To, Points: r.Points,
			})
			cur.byLayer[r.Layer] = len(cur.layers) - 1
			continue
		}
		l := &cur.layers[i]
		if r.From.Before(l.From) {
			l.From = r.From
		}
		if r.To.After(l.To) {
			l.To = r.To
		}
		l.Points += r.Points
	}
	if cur != nil {
		out = append(out, *cur)
	}

	for i := range out {
		out[i].units = sortedKeys(out[i].unitSet)
	}
	return out
}

// sortedKeys отдаёт множество строк отсортированным и обрезанным по потолку:
// число различных единиц у метрики равно числу её объектов, и без потолка одна
// запись каталога растёт вместе с витриной.
func sortedKeys(set map[string]bool) []string {
	out := make([]string, 0, len(set))
	for k := range set {
		out = append(out, k)
	}
	sort.Strings(out)
	if len(out) > maxUnitsReported {
		out = out[:maxUnitsReported]
	}
	return out
}

// Measure выводит род метрики сверкой минутного и часового слоёв.
//
// Час ПРИГОДЕН, когда у часового объекта ровно одна точка со значением и её
// метка совпадает с началом часа, у минутного не меньше двух точек со значением,
// а сумма минутных отличима от их среднего.
//
// Требование выравнивания часовой метки закрывает зоны с неполночасовым
// смещением: слой выводится по выравниванию метки в исходной зоне, а объект
// адресуется часом UTC, поэтому в зоне +0530 часовая точка описывает не тот
// интервал, который покрывают минутные точки того же объекта.
//
// Требование различимости обязательно: в часе, где все значения нули, сумма
// равна среднему, и совпадение с любой из гипотез не значит ничего. Без него
// `walking_asymmetry_percentage` на живом корпусе давала 4 часа «накопительная»
// против 3 «мгновенная» — конфликт из одних нулевых часов.
//
// Вердикт метрики — не меньше трёх согласных часов и НИ ОДНОГО противоречащего.
// Единогласие, а не большинство: противоречащий час означает, что одна из
// гипотез для этой метрики ложна, и объявлять род при известном контрпримере
// нельзя.
func Measure(pairs []store.HourPair) (Style, Basis) {
	basis := Basis{Hours: len(pairs)}
	if len(pairs) == 0 {
		return Unknown, basis
	}

	// Часы приходят от свежих к старым; границы окна отдаём по возрастанию.
	first, last := pairs[len(pairs)-1].Hour, pairs[0].Hour
	basis.FirstHour, basis.LastHour = &first, &last

	var cumulative, instant int
	for _, p := range pairs {
		verdict, ok := verdictOf(p)
		if !ok {
			continue
		}
		basis.Compared++
		switch verdict {
		case Cumulative:
			cumulative++
		case Instant:
			instant++
		}
	}

	// Согласные — часы преобладающей гипотезы, противоречащие — часы другой.
	// Разложение одно и то же независимо от того, объявлен род или нет: при
	// объявленном роде преобладающая гипотеза им и является, а противоречащих
	// ноль по определению правила. Иначе `agreeing` пришлось бы толковать
	// по-разному в двух ветках, и клиент читал бы одно поле двумя способами.
	basis.Agreeing, basis.Conflicting = cumulative, instant
	if instant > cumulative {
		basis.Agreeing, basis.Conflicting = instant, cumulative
	}

	if basis.Conflicting > 0 || basis.Agreeing < MinAgreeing {
		return Unknown, basis
	}
	if cumulative > instant {
		return Cumulative, basis
	}
	return Instant, basis
}

// verdictOf оценивает один час. Второй возврат — был ли час пригоден.
func verdictOf(p store.HourPair) (Style, bool) {
	// Единицы обеих сторон обязаны совпасть. Иначе сверка сравнивает величины
	// разного масштаба: мгновенная метрика, приехавшая в `count/min` минутным
	// слоем и в `count/hour` часовым, даёт `часовое = 60 · среднее = сумма` в
	// полном часе — то есть УВЕРЕННЫЙ ложный `cumulative` при нуле
	// противоречащих часов. Правило единогласия этот случай не ловит по
	// построению: противоречия нет, есть молчание.
	if p.FineUnits != p.CoarseUnits {
		return Unknown, false
	}
	if len(p.Coarse) != 1 {
		return Unknown, false
	}
	if !p.Coarse[0].Start.Equal(p.Hour) {
		return Unknown, false
	}
	coarse, ok := hae.PointValue(p.Coarse[0].Raw)
	if !ok {
		return Unknown, false
	}

	sum, n := sumFine(p.Fine)
	if n < 2 {
		return Unknown, false
	}
	mean := sum / float64(n)
	if closeEnough(sum, mean) {
		return Unknown, false
	}

	switch {
	case closeEnough(coarse, sum):
		return Cumulative, true
	case closeEnough(coarse, mean):
		return Instant, true
	default:
		return Unknown, true
	}
}

// sumFine складывает значения минутных точек в порядке возрастания метки, чтобы
// вердикт не зависел от порядка точек внутри объекта.
func sumFine(points []store.Point) (float64, int) {
	ordered := make([]store.Point, len(points))
	copy(ordered, points)
	sort.SliceStable(ordered, func(i, j int) bool {
		return ordered[i].Start.Before(ordered[j].Start)
	})

	var sum float64
	n := 0
	for _, p := range ordered {
		v, ok := hae.PointValue(p.Raw)
		if !ok {
			continue
		}
		sum += v
		n++
	}
	return sum, n
}
