package httpapi

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"

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

// ФОРМА ПРОВОДА ТОЧЕК. Объявлена здесь и только здесь — доменные типы
// `internal/points` `json`-тегов не несут и до сериализации не доезжают.
// Образец и его цена — `internal/httpapi/catalog.go`; выбирать заново не нужно.

// pointsResponse — оболочка ответа точек.
//
// Поля присутствуют ВСЕГДА, даже когда сообщить нечего: клиент не должен
// выводить исход наличием или отсутствием поля. Отсюда указатели у `layer` и
// `bucket` — `null` означает «слоя нет» и «свёртки не было», а не пустую
// строку, которая была бы законным именем слоя.
type pointsResponse struct {
	Metric string    `json:"metric"`
	From   time.Time `json:"from"`
	To     time.Time `json:"to"`
	Layer  *string   `json:"layer"`
	// Bucket — сетка свёртки. Всегда `null` в этой версии маршрута: свёртку
	// делает соседняя задача. Поле объявлено сразу, потому что менять форму
	// конверта после того, как его скопировали четыре маршрута, — не развилка,
	// а археология.
	Bucket      *string           `json:"bucket"`
	Aggregation seriesAggregation `json:"aggregation"`
	Points      []pointWire       `json:"points"`
}

// seriesAggregation — род свёртки вместе с его применимостью и свежестью.
//
// `applicable` не украшение: род — свойство МЕТРИКИ, слой — свойство ОТДАННОГО
// РЯДА, и сочетание `{"layer": "raw", "style": "cumulative"}` законно, штатно и
// прямо приглашает потребителя сложить интерполяцию самому. Система при этом
// ничего не складывает, а решение у потребителя уже принято по завышенному
// втрое числу.
//
// `last_hour` — ярлык самого свежего часа окна измерения. Окно считается в
// ОБЩИХ часах, а не в часах календаря: выключенная минутная автоматизация HAE
// останавливает их пополнение, окно замирает и продолжает объявлять род.
// Единственный след этого — вот это поле.
type seriesAggregation struct {
	Style      string     `json:"style"`
	Applicable bool       `json:"applicable"`
	LastHour   *time.Time `json:"last_hour"`
}

// pointWire — точка ряда на проводе.
//
// `values` уезжает СЫРЫМ JSON: точки хранятся дословно, и переписывать их в тип
// провода значило бы нарушить инвариант. Конверт вокруг значения при этом
// объявлен и нормализован, как того требует «форма Apple не транслируется».
//
// `ts_end` есть потому, что идентичность точки — интервал, а не метка: под
// одной меткой лежит до трёх записей сна. У точки-измерения он равен `ts`.
type pointWire struct {
	TS       time.Time       `json:"ts"`
	TSEnd    time.Time       `json:"ts_end"`
	TZOffset int             `json:"tz_offset"`
	Units    string          `json:"units"`
	Values   json.RawMessage `json:"values"`
}

// pointsWire переводит ряд домена в форму провода.
//
// Чистая функция от уже прочитанного значения: ни `context`, ни хранилища, ни
// часов. Версия снимка сюда не идёт — она уезжает в `ETag`.
func pointsWire(s points.Series) pointsResponse {
	out := pointsResponse{
		Metric: s.Metric,
		From:   s.From,
		To:     s.To,
		Aggregation: seriesAggregation{
			Style:      s.Style.String(),
			Applicable: s.Applicable,
			// Указатель переносится КАК УКАЗАТЕЛЬ: разыменование дало бы
			// `0001-01-01T00:00:00Z` там, где окна не было, а правдоподобная
			// дата в ответе неотличима от настоящей.
			LastHour: s.LastHour,
		},
		// Непустой срез, а не nil: nil сериализуется в `null`, и клиент
		// прочитал бы «поля нет» вместо «точек нет».
		Points: make([]pointWire, 0, len(s.Points)),
	}
	if s.Layer != "" {
		layer := s.Layer
		out.Layer = &layer
	}
	for _, p := range s.Points {
		out.Points = append(out.Points, pointWire{
			TS:       p.TS,
			TSEnd:    p.End,
			TZOffset: p.OffsetSeconds,
			Units:    p.Units,
			Values:   p.Raw,
		})
	}
	return out
}

// pointsScope — область действия метки ответа: ХЕШ канонизированной формы
// запроса.
//
// Живёт В ТРАНСПОРТЕ, и это решение, а не случайность: у каталога область тоже
// транспортная (`scopeMetrics`), а помощник `etag` прямо просит маршрут положить
// сюда канонизированную форму запроса. Считай её домен — у одного понятия
// оказалось бы два дома, и три следующих маршрута выбирали бы между ними
// монетой. Заодно домен перестал бы носить транспортный артефакт: префикс и hex
// — это форма метки HTTP, а форму провода объявляет транспорт (ADR).
//
// Метка действительна в пределах ОДНОГО набора данных, а ответ этого маршрута
// есть функция параметров. Метка без них однажды подтвердила бы неизменность
// чужого набора — молча и без следов.
//
// Канонизация: границы приводятся к UTC в RFC 3339 с ПОЛНОЙ точностью. Полной,
// а не посекундной, потому что отбор точек идёт по полной метке: два запроса,
// различающиеся долей секунды, дают разные ряды, и одна область на них
// означала бы `304` на чужом наборе данных. Зона при этом канонизируется —
// одно и то же время, записанное разными смещениями, даёт одну область.
//
// Слой берётся ЗАПРОШЕННЫЙ, а не выбранный: областью является запрос, а смену
// выбранного слоя от новых данных ловит версия витрины.
//
// ХЕШ, а не сама форма, и обе причины из `docs/security.md`. Имя метрики
// приходит из чужого тела дословно и ничем не ограничено — в заголовке ответа
// оно дало бы `ETag` в тысячи байт, притом что тот же вход в лог уезжает
// обрезанным. И оно законно содержит кавычку, которая по RFC 9110 кончает
// метку: собственный `scanETag` проекта обрезал бы её ровно там, и условный
// запрос по такой метрике не сработал бы никогда, а симптом увёл бы отладку в
// помощника. Длина впереди остаётся внутри хешируемой строки: без неё имя с
// разделителем склеилось бы с соседним полем.
func pointsScope(req points.Request) string {
	canonical := fmt.Sprintf("%d:%s|%s|%s|%s",
		len(req.Metric), req.Metric,
		req.From.UTC().Format(time.RFC3339Nano),
		req.To.UTC().Format(time.RFC3339Nano),
		req.Layer)
	sum := sha256.Sum256([]byte(canonical))
	return "points." + hex.EncodeToString(sum[:scopeBytes])
}

// scopeBytes — сколько байтов хеша попадает в область.
//
// Шестнадцать: 128 бит, то есть столкновение неотличимо от невозможного, а
// заголовок остаётся короче метки каталога. Хеш здесь не криптографический
// секрет — он ограничитель длины и экранирование разом.
const scopeBytes = 16

// handlePoints отдаёт точки метрики за период.
func (a *api) handlePoints(w http.ResponseWriter, r *http.Request) {
	req, msg := parsePointsRequest(r)
	if msg != "" {
		// Отказ до чтения витрины: невозможный запрос не должен стоить снимка.
		// В сообщении нет ни одного значения из запроса — оно уедет клиенту, а
		// в запросе имя метрики и границы периода.
		writeError(w, http.StatusBadRequest, msg)
		return
	}

	series, err := a.points.Series(r.Context(), req)
	if err != nil {
		// Исход операции логирует доменный слой, транспорт только переводит его
		// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
		// в нём имена колонок и форма запроса.
		writeError(w, http.StatusInternalServerError, "ряд точек не собрался")
		return
	}

	// Область действия метки — канонизированная форма запроса: ответ этого
	// маршрута есть функция параметров, и метка без них однажды подтвердила бы
	// неизменность чужого набора данных. Горизонт измерения в метке уже есть —
	// его кладёт домен вместе с версией витрины.
	setReadHeaders(w, etag(pointsScope(req), series.Version))
	if err := writeJSON(w, http.StatusOK, pointsWire(series)); err != nil {
		// Тело оборвалось на середине: дедлайн записи, ушедший клиент, полный
		// буфер посредника. Код ответа уже отдан, и `accessLog` напишет `200` —
		// то есть единственный канал наблюдаемости сообщит успех о неотданном
		// ответе. Ряд ничем не ограничен по размеру (предел — соседняя задача),
		// поэтому случай не гипотетический: неделя нижнего слоя это 280 МиБ.
		//
		// WARN, а не DEBUG, и это исправление находки эксплуатационного прохода:
		// боевой уровень логирования — `info` (`config.docker.toml`), то есть
		// запись уровня `DEBUG` не прошла бы фильтр НИКОГДА, и единственный
		// признак недоставленного тела не существовал бы в проде вовсе.
		// Обесценивания уровня здесь нет: событие не периодическое — оно
		// означает, что потребитель получил битый JSON.
		//
		// Значений точек и границ запроса в записи нет; имя метрики обрезано.
		a.log.WarnContext(r.Context(), "points response truncated",
			"capability", "query", "metric", catalog.ClipMetric(req.Metric), "error", err)
	}
}

// parsePointsRequest разбирает параметры маршрута точек.
//
// Второй возврат — человекочитаемая причина отказа; пустая строка означает, что
// запрос принят. Строка, а не ошибка: она целиком уезжает клиенту, поэтому
// обязана быть составлена здесь и не содержать ни одного значения из запроса.
func parsePointsRequest(r *http.Request) (points.Request, string) {
	q := r.URL.Query()

	// Свёртка по сетке ещё не поддержана, и молчать об этом нельзя: клиент,
	// попросивший суточную сетку и получивший полный минутный ряд, заметил бы
	// подмену, только пересчитав точки. Это зеркало того самого промаха, ради
	// которого «сетка задана явно» вообще различается.
	if q.Has("bucket") {
		return points.Request{}, "свёртка по сетке ещё не поддержана: параметр bucket не принимается"
	}

	from, msg := parseBound(q, "from")
	if msg != "" {
		return points.Request{}, msg
	}
	to, msg := parseBound(q, "to")
	if msg != "" {
		return points.Request{}, msg
	}
	if !from.Before(to) {
		return points.Request{}, "период пуст: from должен быть строго раньше to"
	}

	// `Has`, а не `Get() != ""`, и симметрично `bucket`. Пустое значение
	// (`?layer=`) — самый частый способ промахнуться: шаблон клиента с
	// невыставленной переменной. Разбор по непустоте молча включил бы
	// автоматический выбор, и клиент, спросивший разрез поимённо, не отличил бы
	// свой промах от ответа по существу.
	layer := q.Get("layer")
	if q.Has("layer") && !hae.Known(hae.Layer(layer)) {
		return points.Request{}, "неизвестный слой: допустимы " + knownLayers
	}

	return points.Request{Metric: metricFromPath(r), From: from, To: to, Layer: layer}, ""
}

// parseBound разбирает границу периода.
//
// Только RFC 3339 и только с явным смещением зоны. Голая дата отвергается не из
// строгости: у неё нет зоны, а вопрос «в какой зоне считать сутки» в проекте
// открыт отдельной задачей. Принять её значило бы выбрать зону за клиента молча.
func parseBound(q url.Values, name string) (time.Time, string) {
	raw := q.Get(name)
	if raw == "" {
		return time.Time{}, "параметр " + name + " обязателен"
	}
	t, err := time.Parse(time.RFC3339, raw)
	if err != nil {
		return time.Time{}, "параметр " + name +
			": ожидается метка времени RFC 3339 с явным смещением зоны, например 2026-01-01T00:00:00Z"
	}

	// Год после приведения к UTC обязан остаться четырёхзначным, и это не
	// придирка к календарю. Хранилище адресует объекты строкой RFC 3339, а
	// сравнение границ идёт лексикографически: `9999-12-31T23:00:00-07:00`
	// превращается в `10000-01-01T06:00:00Z`, который меньше любой настоящей
	// метки как строка, — и запрос молча отдал бы пустой ряд при непустых
	// данных. Отказ здесь честнее пустоты: тот же разряд ловится и на входе
	// приёма, но там он унаследован и лечится не тут.
	if y := t.UTC().Year(); y < 1 || y > 9999 {
		return time.Time{}, "параметр " + name + ": год после приведения к UTC вне диапазона 1–9999"
	}
	return t.UTC(), ""
}

// knownLayers — перечень допустимых слоёв ДЛЯ СООБЩЕНИЯ КЛИЕНТУ, собранный из
// того же словаря, что и проверка. Текст отказа уезжает наружу и потому обязан
// перечислять ровно то, что принимается: разъехавшись, он врал бы клиенту.
var knownLayers = func() string {
	out := make([]string, 0, len(hae.Layers))
	for _, l := range hae.Layers {
		out = append(out, string(l))
	}
	return strings.Join(out, ", ")
}()

// metricFromPath достаёт имя метрики из пути.
//
// Решение принимается по `RawPath`, а НЕ по успеху декодирования, и это ровно
// то место, где легко ошибиться. `chi` сопоставляет по сырому пути только когда
// тот непуст, то есть когда `net/url` увидел в пути экранирование; тогда
// `{metric}` приезжает закодированным и его надо декодировать. Когда `RawPath`
// пуст, `chi` отдаёт уже декодированное имя — и второе декодирование испортило
// бы его молча.
//
// Цена ошибки построена и прогнана враждебным проходом: метрика с именем
// `a%41b` (имена приходят из тела доставки дословно) кодируется клиентом в
// `a%2541b`, `net/url` декодирует это в `a%41b` и оставляет `RawPath` пустым —
// а второе декодирование давало `aAb`, то есть маршрут отвечал `200` и данными
// ДРУГОЙ метрики.
//
// Отказ декодирования не является отказом маршрута: берём имя как есть — оно
// всё равно не совпадёт ни с одной метрикой витрины, и клиент получит честный
// пустой ряд, а не `400` на своё же имя.
func metricFromPath(r *http.Request) string {
	raw := chi.URLParam(r, "metric")
	if r.URL.RawPath == "" {
		return raw
	}
	if decoded, err := url.PathUnescape(raw); err == nil {
		return decoded
	}
	return raw
}
