// Package points — ряд точек одной метрики за период.
//
// Отвечает на вопрос потребителя «дай значения» — в отличие от каталога,
// который отвечает «что у тебя есть». Пакет производен от витрины и ничего в
// неё не пишет.
//
// Три решения этого узла живут здесь, потому что все три — правила, а не
// выборки:
//
//   - какой слой отдать, когда клиент его не назвал;
//   - применим ли измеренный род к отданному ряду (нижний слой HAE не
//     суммируется никогда, а род — свойство метрики, не ряда);
//   - из чего собрана метка ответа.
//
// Род при этом НЕ измеряется здесь: правило измерения одно и живёт в
// `internal/catalog`. Второй его экземпляр разошёлся бы с первым молча, а на
// роде строится арифметика года.
package points

import (
	"context"
	"encoding/json"
	"log/slog"
	"time"

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

// ФОРМЫ ПРОВОДА В ЭТОМ ПАКЕТЕ НЕТ. Типы ниже — форма ответа use-case:
// `json`-тегов они не несут и до сериализации не доезжают. Публичный контракт
// объявляет транспорт (`internal/httpapi`), см. ADR о форме провода.

// Request — запрос ряда. Границы уже разобраны и нормализованы транспортом;
// период — полуинтервал `[From, To)`.
type Request struct {
	Metric string
	From   time.Time
	To     time.Time
	// Layer — явно запрошенный слой; пустая строка означает «выбери сам».
	Layer string
}

// Point — точка ряда.
//
// `Raw` — содержимое ровно в том виде, в каком его сохранило хранилище: точки
// хранятся дословно, и нормализовано у них только время.
type Point struct {
	TS            time.Time
	End           time.Time
	OffsetSeconds int
	Units         string
	Raw           json.RawMessage
}

// Series — ответ маршрута точек вместе с версией ответа.
//
// Версия пустая, когда подписать ответ нечем: витрина изменилась, пока ответ
// собирался, или прочитать её версию не удалось. Это не отказ.
type Series struct {
	Version string
	Metric  string
	From    time.Time
	To      time.Time
	// Layer — слой, из которого собран ряд. Пустая строка означает, что слой
	// выбирала система и выбирать было не из чего; явно запрошенный слой
	// уезжает здесь всегда, даже когда ряд пуст.
	Layer string
	// Style — измеренный род метрики, тем же правилом и тем же окном, что у
	// каталога.
	Style catalog.Style
	// Applicable — применим ли объявленный род к ЭТОМУ ряду.
	Applicable bool
	// LastHour — ярлык самого свежего часа окна измерения; nil при пустом окне.
	LastHour *time.Time
	Points   []Point
}

// 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}
}

// Series собирает ряд точек метрики за период.
//
// Всё, от чего зависит ответ, снимается ОДНОЙ транзакцией чтения: охваты слоёв,
// точки выбранного слоя и объекты окна измерения. Пара проб версии вокруг неё
// нужна для метки, а непротиворечивость тела даёт транзакция — проба
// расхождение обнаружила бы, но тело всё равно уехало бы клиенту.
//
// Горизонт снимается ОДИН РАЗ и уходит и в окно измерения, и в метку: род есть
// функция горизонта, а горизонт едет вместе с часами. Сними их порознь — и
// метка однажды подтвердит неизменность ответа, чей род уже перевернулся.
func (s *Service) Series(ctx context.Context, req Request) (Series, error) {
	horizon := catalog.Horizon()

	var snap store.SeriesSnapshot
	version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
		var err error
		snap, err = s.store.ReadSeries(ctx, store.SeriesWindow{
			Metric:  req.Metric,
			From:    req.From,
			To:      req.To,
			Layer:   req.Layer,
			Layers:  layerNames,
			Measure: catalog.MeasureWindow(horizon),
		}, func(spans []store.LayerSpan) string {
			return pickLayer(req.From, req.To, spans)
		})
		return err
	})
	if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
		// Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в
		// ответ и второй раз её не пишет. Границ запроса и значений точек в
		// записи нет — данные о здоровье чувствительнее токенов; имя метрики
		// обрезано тем же пределом, что у каталога: оно приходит из тела
		// дословно, а запись повторяется на каждый запрос.
		//
		// Отмена снаружи и занятость базы означают «не сделано», а не «не
		// выходит»: клиент, оборвавший запрос по своему тайм-ауту, не должен
		// давать владельцу ERROR.
		if store.Transient(err) {
			s.log.DebugContext(ctx, "series interrupted", "capability", "query",
				"metric", catalog.ClipMetric(req.Metric), "error", err)
		} else {
			s.log.ErrorContext(ctx, "series failed", "capability", "query",
				"metric", catalog.ClipMetric(req.Metric), "error", err)
		}
		return Series{}, err
	}

	// Предупреждения измерения (противоречащий род, данные из будущего) здесь
	// НЕ пишутся: они привилегия каталога. Агент опрашивает по расписанию, и
	// WARN на каждый опрос обесценил бы уровень ровно так же, как обесценила бы
	// его строка на каждый `304`.
	style, basis := catalog.Measure(snap.Pairs)

	out := Series{
		Version:    catalog.Stamp(version, horizon),
		Metric:     req.Metric,
		From:       req.From,
		To:         req.To,
		Layer:      snap.Layer,
		Style:      style,
		Applicable: applicable(style, snap.Layer),
		LastHour:   basis.LastHour,
		// Непустой срез, а не nil: пустой ряд обязан уехать клиенту как `[]`.
		Points: make([]Point, 0, len(snap.Points)),
	}
	for _, p := range snap.Points {
		out.Points = append(out.Points, Point{
			TS:            p.Start,
			End:           p.End,
			OffsetSeconds: p.OffsetSeconds,
			Units:         p.Units,
			Raw:           p.Raw,
		})
	}
	if version == "" {
		s.log.DebugContext(ctx, "series unsigned", "capability", "query",
			"metric", catalog.ClipMetric(req.Metric))
	}
	return out, nil
}

// layerNames — тот же словарь `hae.Layers`, переведённый в строки для выборки.
//
// Выводится из словаря, а не перечисляется заново: собственный список разошёлся
// бы с ним молча. Хранилищу перечень нужен по эксплуатационной причине — без
// предиката по слою выборка охватов просматривает все строки метрики за всю
// историю (измерено проходом `ops`: 13.9 мс против 0.026 мс), и цена росла бы
// вместе с возрастом сервиса при любой ширине запроса.
var layerNames = func() []string {
	out := make([]string, 0, len(hae.Layers))
	for _, l := range hae.Layers {
		out = append(out, string(l))
	}
	return out
}()

// pickLayer выбирает слой по ОХВАТУ точек внутри периода.
//
// Охват — длина пересечения отрезка [первая метка слоя, последняя метка слоя] с
// запрошенным периодом. Слой с пустым пересечением выбывает. Побеждает
// наибольший охват, при равенстве — самый мелкий слой.
//
// Охват меряется метками ТОЧЕК, а не часами объектов, и это не придирка.
// Объекты адресуются часом, поэтому выборка обязана быть шире запроса (точка
// 10:59 живёт в объекте 10:00), а ряд отбирается точной меткой. На периоде
// [10:30, 10:45) часовой слой имеет объект 10:00 с единственной точкой в 10:00,
// минутный — объект 10:00 с точками 10:31…10:44. По часам объектов охваты
// равны, побеждает часовой — и ответ уходит пустым при непустых минутных
// данных. По меткам точек часовой выбывает сразу.
//
// Число точек мерой не является: нижний слой за три плотных дня даёт их больше,
// чем часовой за год, — и «вес за год» вернул бы три дня, не сказав об этом ни
// словом.
func pickLayer(from, to time.Time, spans []store.LayerSpan) string {
	best := ""
	var bestCover time.Duration
	for _, sp := range spans {
		if sp.Last.Before(from) || !sp.First.Before(to) {
			continue
		}
		start, end := sp.First, sp.Last
		if start.Before(from) {
			start = from
		}
		if end.After(to) {
			end = to
		}
		cover := end.Sub(start)
		finer := hae.Rank(hae.Layer(sp.Layer)) < hae.Rank(hae.Layer(best))
		if best == "" || cover > bestCover || (cover == bestCover && finer) {
			best, bestCover = sp.Layer, cover
		}
	}
	return best
}

// applicable отвечает, можно ли применить объявленный род к ЭТОМУ ряду.
//
// Род — свойство метрики, слой — свойство ряда, и их сочетание бывает опасным.
// Нижний слой HAE это интерполяция, а не сэмплы: сумма по нему завышает втрое
// (находка 34). Конверт, объявляющий `cumulative` рядом с рядом из `raw` и
// молчащий о неприменимости, приглашает потребителя сложить интерполяцию
// самому — система при этом не складывает ничего, а решение у потребителя уже
// принято по завышенному числу.
//
// Слой `sample` под запрет не подпадает: это настоящие сэмплы HealthKit из
// родного экспорта, а не развёртка HAE.
func applicable(style catalog.Style, layer string) bool {
	if style == catalog.Unknown || layer == "" {
		return false
	}
	return style != catalog.Cumulative || layer != string(hae.LayerRaw)
}
