// Package httpapi — HTTP-транспорт healthlog: приём пакетов и (позже) read API.
//
// Транспорт тонкий: разбирает запрос, зовёт use-case, переводит его ошибку в
// ответ. Исход операции логирует use-case, а не транспорт.
package httpapi

import (
	"crypto/subtle"
	"encoding/json"
	"log/slog"
	"net/http"
	"time"

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

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

// Options — зависимости и настройки транспорта.
type Options struct {
	Ingest      *ingest.Service
	Catalog     *catalog.Service
	Points      *points.Service
	Log         *slog.Logger
	WriteTokens []string
	ReadTokens  []string
	MaxBodyMB   int
	// IngestWriteBudget — сколько отводится маршруту приёма на чтение тела
	// вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout
	// сервера», и полагаться на него нельзя, см. handleIngest.
	IngestWriteBudget time.Duration
}

type api struct {
	ingest       *ingest.Service
	catalog      *catalog.Service
	points       *points.Service
	log          *slog.Logger
	writeTokens  []string
	readTokens   []string
	maxBody      int64
	ingestBudget time.Duration
}

// New собирает HTTP-роутер.
func New(o Options) http.Handler {
	a := &api{
		ingest:       o.Ingest,
		catalog:      o.Catalog,
		points:       o.Points,
		log:          o.Log,
		writeTokens:  o.WriteTokens,
		readTokens:   o.ReadTokens,
		maxBody:      int64(o.MaxBodyMB) << 20,
		ingestBudget: o.IngestWriteBudget,
	}

	r := chi.NewRouter()
	r.Use(middleware.Recoverer)
	r.Use(a.accessLog)

	r.Get("/healthz", a.handleHealthz)
	r.Route("/api/v1", func(r chi.Router) {
		r.With(requireToken(a.writeTokens)).Post("/ingest", a.handleIngest)
		r.With(requireToken(a.readTokens)).Get("/metrics", a.handleMetrics)
		// Маршрут точек стоит РЯДОМ с каталогом, а не поверх него: у chi
		// литеральный `/metrics` и шаблон `/metrics/{metric}` — разные узлы, и
		// каталог остаётся достижим. Утверждается это тестом, а не верой.
		r.With(requireToken(a.readTokens)).Get("/metrics/{metric}", a.handlePoints)
	})
	return r
}

func (a *api) handleHealthz(w http.ResponseWriter, _ *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(`{"status":"ok"}`))
}

// requireToken проверяет токен контура. Пустой список токенов = проверка
// выключена: локальный запуск в доверенной сети. О выключенной проверке
// сервис предупреждает на старте.
//
// Проверка ОДНА на оба контура, параметризованная списком. Копия отличалась бы
// одним полем и несла бы три решения сразу — сравнение за постоянное время,
// «пустой список = выключено» и текст 401; правка любого из них в одном месте
// не дала бы ни ошибки компиляции, ни красного теста, а речь о контуре чтения
// данных о здоровье.
//
// Контуры при этом раздельны: списки разные, и токен приёма маршрут чтения не
// открывает. Схема строгая — токеном считается только значение после `Bearer `.
func requireToken(tokens []string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if len(tokens) == 0 {
				next.ServeHTTP(w, r)
				return
			}
			if !tokenAllowed(bearer(r), tokens) {
				writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен")
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

func bearer(r *http.Request) string {
	const prefix = "Bearer "
	h := r.Header.Get("Authorization")
	if len(h) > len(prefix) && h[:len(prefix)] == prefix {
		return h[len(prefix):]
	}
	return ""
}

// tokenAllowed сравнивает токен за постоянное время: побайтовое сравнение с
// ранним выходом утекает длину совпадающего префикса.
func tokenAllowed(got string, allowed []string) bool {
	ok := false
	for _, want := range allowed {
		if subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 {
			ok = true
		}
	}
	return ok
}

// accessLog пишет одну запись на запрос. Рутинно-частые эндпоинты
// (healthcheck) — на DEBUG, чтобы не забивать аудит.
func (a *api) accessLog(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)

		next.ServeHTTP(ww, r)

		level := slog.LevelInfo
		if r.URL.Path == "/healthz" {
			level = slog.LevelDebug
		}
		a.log.Log(r.Context(), level, "http request",
			"transport", "http",
			"http.method", r.Method,
			"http.route", routePattern(r),
			"http.status_code", ww.Status(),
			"duration_ms", time.Since(start).Milliseconds())
	})
}

func routePattern(r *http.Request) string {
	if rctx := chi.RouteContext(r.Context()); rctx != nil {
		if p := rctx.RoutePattern(); p != "" {
			return p
		}
	}
	return r.URL.Path
}

// writeJSON — единственный сериализатор тел ответа.
//
// Экранирование HTML ВЫКЛЮЧЕНО, и это не косметика. `encoding/json` по
// умолчанию превращает `&`, `<` и `>` в `\u0026`, `\u003c`, `\u003e`; на
// маршруте, отдающем дословно сохранённое содержимое точки, это прямо ломает
// обещание дословности — имя источника приходит с телефона пользовательской
// строкой и законно содержит `&`. Хранилище этот же капкан уже проходило и
// обезвредило тем же способом (`store.encodePayload`).
//
// Правило общее для всех читающих маршрутов намеренно: механизм один, и
// решать его заново на каждом маршруте значило бы завести второй способ.
// Отказ записи ВОЗВРАЩАЕТСЯ, а не глушится: код ответа отдан до сериализации,
// поэтому оборванное на середине тело снаружи неотличимо от успеха, а
// `accessLog` честно напишет `200`. Кто из вызывающих обязан об этом сказать —
// решает он сам; глушить молча нельзя ни одному.
func writeJSON(w http.ResponseWriter, status int, v any) error {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	enc := json.NewEncoder(w)
	enc.SetEscapeHTML(false)
	return enc.Encode(v)
}

// errorWire — форма провода тела отказа, общая для всех маршрутов.
//
// Объявленный тип, а не `map[string]string`: у читающего маршрута тело отказа
// такая же часть публичного контракта, как и успешный ответ, и клиент видит его
// чаще. Карта же делает «два ответа совпадают побайтово» свойством библиотеки, а
// не решения, и переименование ключа `error` не увидел бы ни один сторож — ни
// обход графа типов (карта строк проходит как стандартный тип), ни байтовый
// литерал (тел отказа он не закреплял).
type errorWire struct {
	Error string `json:"error"`
}

// writeError отдаёт человекочитаемое сообщение, а не текст ошибки: в тексте
// имена колонок и форма запроса.
func writeError(w http.ResponseWriter, status int, msg string) {
	// Тело отказа — десятки байт: оборваться на нём нечему, и сообщать о
	// таком обрыве было бы шумом.
	_ = writeJSON(w, status, errorWire{Error: msg})
}
