package httpapi

import (
	"net/http"
	"time"

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

// ФОРМА ПРОВОДА КАТАЛОГА. Публичный контракт объявлен здесь и только здесь —
// доменные типы `internal/catalog` `json`-тегов не несут и до сериализации не
// доезжают. Отсюда следует то, ради чего это сделано: переименование поля в
// домене байты ответа не меняет, а смена контракта есть правка вот этих
// объявлений, то есть действие, а не побочный эффект.
//
// Обратная цена взята сознательно: новое поле домена в ответ само не попадёт —
// его обязан перечислить перевод ниже. Решение, цена обеих сторон и разбор
// чужих решений — `docs/architecture.md`, раздел «Read API», подраздел «Форма
// провода».
//
// ОБРАЗЕЦ ДЛЯ СЛЕДУЮЩИХ МАРШРУТОВ: точки, тренировки, записи и MCP объявляют
// свою форму так же — типами рядом с обработчиком, переводом-присваиванием,
// строкой в таблице `wire_internal_test.go`. Выбирать заново не нужно.

// catalogResponse — оболочка ответа каталога.
//
// Объект, а не голый массив: список метрик — не единственное, что каталогу
// когда-нибудь придётся отдать, а массив верхнего уровня расширить нечем.
type catalogResponse struct {
	Metrics []metricWire `json:"metrics"`
}

// metricWire — запись каталога на проводе.
type metricWire struct {
	Metric      string          `json:"metric"`
	Units       []string        `json:"units"`
	Aggregation aggregationWire `json:"aggregation"`
	Layers      []layerWire     `json:"layers"`
}

// aggregationWire — род агрегации вместе с основанием, ПЛОСКО.
//
// Поля основания перечислены здесь поимённо, а не встроены структурой: в домене
// `Basis` встроен в `Aggregation`, и плоскость объекта была следствием этого
// встраивания — разъединение в домене молча дало бы клиенту вложенный объект.
// Теперь плоскость — записанное решение транспорта.
//
// Род — обычная `string`: словарь (`cumulative`/`instant`/`unknown`) клиент
// видит строкой, и кладёт её сюда транспорт. Значения словаря при этом остаются
// доменные (`catalog.Style.String()`) — свой `switch` здесь завёл бы второй
// словарь, который разошёлся бы с первым молча.
type aggregationWire struct {
	Style       string     `json:"style"`
	Hours       int        `json:"hours"`
	Compared    int        `json:"compared"`
	Agreeing    int        `json:"agreeing"`
	Conflicting int        `json:"conflicting"`
	FirstHour   *time.Time `json:"first_hour"`
	LastHour    *time.Time `json:"last_hour"`
}

// layerWire — разрез метрики на проводе.
type layerWire struct {
	Layer  string    `json:"layer"`
	From   time.Time `json:"from"`
	To     time.Time `json:"to"`
	Points int       `json:"points"`
}

// catalogWire переводит записи домена в форму провода.
//
// Чистая функция от уже прочитанного значения: ни `context`, ни хранилища, ни
// часов. Версия снимка сюда не идёт — она уезжает в `ETag`, и снимок у тела и у
// метки один, иначе `304` подтверждал бы одно состояние, а `200` отдавал другое.
//
// Присваивание поле в поле, а не копирование структуры: это и есть то место, где
// смена контракта становится видимой правкой.
func catalogWire(metrics []catalog.Metric) catalogResponse {
	// Непустой срез, а не nil: nil сериализуется в `null`, и пустая витрина
	// отдавала бы клиенту `"metrics": null` вместо `[]`. Домен это уже
	// обеспечивает, но контракт объявлен здесь — значит и держится здесь.
	out := make([]metricWire, 0, len(metrics))
	for _, m := range metrics {
		layers := make([]layerWire, 0, len(m.Layers))
		for _, l := range m.Layers {
			layers = append(layers, layerWire{
				Layer:  l.Layer,
				From:   l.From,
				To:     l.To,
				Points: l.Points,
			})
		}
		// Так же, как слои: `make` + `append`, а не копирование среза домена.
		// Ветвления «если nil» здесь нет намеренно — оно было бы веткой, которую
		// сегодня не проходит ни один вход (домен nil не отдаёт), то есть
		// непроверяемой страховкой. Конструкция даёт непустой срез всегда.
		units := make([]string, 0, len(m.Units))
		units = append(units, m.Units...)
		out = append(out, metricWire{
			Metric: m.Metric,
			Units:  units,
			Aggregation: aggregationWire{
				Style:       m.Aggregation.Style.String(),
				Hours:       m.Aggregation.Hours,
				Compared:    m.Aggregation.Compared,
				Agreeing:    m.Aggregation.Agreeing,
				Conflicting: m.Aggregation.Conflicting,
				// Указатели переносятся КАК УКАЗАТЕЛИ: разыменование дало бы
				// `0001-01-01T00:00:00Z` там, где окно не измерено, а
				// правдоподобная дата в ответе неотличима от настоящей.
				FirstHour: m.Aggregation.FirstHour,
				LastHour:  m.Aggregation.LastHour,
			},
			Layers: layers,
		})
	}
	return catalogResponse{Metrics: out}
}

// handleMetrics отдаёт каталог разрезов с измеренным родом агрегации.
//
// Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки
// снимка: в этом весь смысл — самый частый запрос потребителя есть повтор
// неизменившегося, и он не должен стоить ни снимка, ни разжатия точек.
// scopeMetrics — область действия метки каталога. Ответ маршрута не зависит от
// параметров запроса, поэтому область постоянна; у точек и MCP на её месте
// будет канонизированная форма запроса.
const scopeMetrics = "metrics"

func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
	// Values, а не Get: `If-None-Match` клиент вправе прислать несколькими
	// строками, и `Get` увидел бы только первую — часть меток осталась бы
	// нерассмотренной.
	if cond := r.Header.Values("If-None-Match"); len(cond) > 0 {
		// Отказ пробы глушится намеренно: он означает лишь, что условного
		// ответа не будет, — а настоящий отказ базы всплывёт сборкой каталога
		// строкой ниже и будет назван ею один раз.
		// Версию спрашиваем У КАТАЛОГА, а не у хранилища: ответ есть функция не
		// только состояния витрины, и что ещё в него входит, знает домен. Read
		// API точек ответит здесь же своей версией, включающей параметры
		// запроса, — транспорту эти правила знать незачем.
		if version, err := a.catalog.Version(r.Context()); err == nil {
			if tag := etag(scopeMetrics, version); notModified(cond, tag) {
				writeNotModified(w, tag)
				return
			}
		}
	}

	snap, err := a.catalog.Metrics(r.Context())
	if err != nil {
		// Исход операции логирует доменный слой, транспорт только переводит его
		// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
		// в нём имена колонок и форма запроса.
		writeError(w, http.StatusInternalServerError, "каталог не собрался")
		return
	}
	// Версии может не быть — витрина изменилась, пока ответ собирался. Тогда
	// ответ уходит без метки: это ровно поведение до появления условного
	// запроса, то есть деградация в безопасную сторону.
	setReadHeaders(w, etag(scopeMetrics, snap.Version))
	// Обрыв записи каталога глушится: ответ здесь — три десятка строк, и
	// оборваться на нём нечему. У маршрута точек ряд ничем не ограничен, и там
	// отказ записи наблюдается отдельным чекпоинтом.
	_ = writeJSON(w, http.StatusOK, catalogWire(snap.Metrics))
}
