package httpapi_test

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

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

func getCatalog(t *testing.T, h http.Handler, auth string) *httptest.ResponseRecorder {
	t.Helper()

	req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
	if auth != "" {
		req.Header.Set("Authorization", auth)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

// Пустая витрина отдаёт `[]`, а не `null`. Сравниваются БАЙТЫ: nil-срез
// сериализуется в `null`, и тест, сличающий разобранные структуры, этого не
// увидел бы — а клиент увидел бы сразу.
func TestКаталогПустойВитриныОтдаётПустойСписок(t *testing.T) {
	h, _, _ := newAPITokens(t, nil, nil)

	rec := getCatalog(t, h, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("статус %d, тело %s", rec.Code, rec.Body.String())
	}
	if got := strings.TrimSpace(rec.Body.String()); got != `{"metrics":[]}` {
		t.Errorf("тело %q, ждали {\"metrics\":[]}", got)
	}
}

// Границы неизмеренного окна уезжают как `null`, а не как правдоподобная метка
// `0001-01-01`: нулевое время в ответе неотличимо от данных.
//
// Сравниваются БАЙТЫ ЦЕЛОГО ТЕЛА, а не подстроки. Это ветка `*time.Time`, где
// перевод домена в форму провода идёт присваиванием поле в поле: разыменуй
// указатель — и получишь `"first_hour":"0001-01-01T00:00:00Z"`, в котором
// подстрока `"first_hour"` по-прежнему есть, а обещание нарушено.
func TestКаталогНеизмеренноеОкноОтдаётБайтыСNull(t *testing.T) {
	h, st, _ := newAPITokens(t, nil, nil)

	at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
	_, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{
		Metric: "vo2_max", Layer: "raw", Units: "ml/(kg·min)",
		Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":42}`)},
	}}}, store.DeliveryRef{ID: "d"})
	if err != nil {
		t.Fatalf("слияние: %v", err)
	}

	want := `{"metrics":[{"metric":"vo2_max","units":["ml/(kg·min)"],` +
		`"aggregation":{"style":"unknown","hours":0,"compared":0,"agreeing":0,` +
		`"conflicting":0,"first_hour":null,"last_hour":null},` +
		`"layers":[{"layer":"raw","from":"2026-06-01T10:00:00Z","to":"2026-06-01T10:00:00Z","points":1}]}]}`

	if got := strings.TrimSpace(getCatalog(t, h, "").Body.String()); got != want {
		t.Errorf("форма неизмеренного окна изменилась:\n получили %s\n ждали   %s", got, want)
	}
}

func TestКаталогТребуетТокенЧтения(t *testing.T) {
	write := []string{"write-token"}
	read := []string{"read-token"}

	cases := []struct {
		name string
		auth string
		want int
	}{
		{"без заголовка", "", http.StatusUnauthorized},
		{"токен приёма", "Bearer write-token", http.StatusUnauthorized},
		{"голое значение без схемы", "read-token", http.StatusUnauthorized},
		{"чужой токен", "Bearer nope", http.StatusUnauthorized},
		{"токен чтения", "Bearer read-token", http.StatusOK},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			h, _, _ := newAPITokens(t, write, read)

			rec := getCatalog(t, h, c.auth)
			if rec.Code != c.want {
				t.Errorf("статус %d, ждали %d (тело %s)", rec.Code, c.want, rec.Body.String())
			}
		})
	}
}

// Пустой список токенов чтения = проверка выключена. Это симметрия с приёмом, и
// цена её названа в конфиге: открытое чтение — выгрузка истории здоровья.
func TestКаталогБезТокеновОтдаётся(t *testing.T) {
	h, _, _ := newAPITokens(t, []string{"write-token"}, nil)

	if rec := getCatalog(t, h, ""); rec.Code != http.StatusOK {
		t.Errorf("статус %d, ждали 200", rec.Code)
	}
}

// Токен чтения — такой же секрет, как токен приёма: посланный на маршрут приёма
// заголовком с произвольным именем, он не имеет права осесть в учёте доставки.
func TestТокенЧтенияНеОседаетВУчётеДоставки(t *testing.T) {
	h, st, _ := newAPITokens(t, nil, []string{"read-token"})

	req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Whatever", "read-token")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("статус %d", rec.Code)
	}

	d, err := st.LastDelivery(context.Background())
	if err != nil {
		t.Fatalf("доставка: %v", err)
	}
	if strings.Contains(d.Headers, "read-token") {
		t.Errorf("токен чтения сохранён в заголовках доставки: %s", d.Headers)
	}
}

// Форма ответа закреплена БАЙТАМИ на непустой витрине, а не подстроками.
//
// Литерал ниже — ДЕТЕКТОР ИЗМЕНЕНИЯ ФОРМЫ, а не сам контракт. Роли разведены
// намеренно: источником истины контракта станет рукописная OpenAPI-спека
// (решение владельца 2026-08-04, задача `openapi-spec`), а сверку спеки с
// маршрутами внесёт в гейт задача `openapi-gate-check`. Детектор при этом
// краснеет РАНЬШЕ гейта — в момент правки, — и в этом весь его смысл.
//
// Правишь литерал — правишь публичный контракт. Значит рядом обязана лежать
// правка спеки, а не только «чтобы позеленело».
func TestКаталогОтдаётОжидаемыеБайты(t *testing.T) {
	h, st, _ := newAPITokens(t, nil, nil)

	base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
	var points []store.IncomingPoint
	for i := range 4 {
		at := base.Add(time.Duration(i) * time.Hour)
		points = append(points, store.IncomingPoint{
			Metric: "step_count", Layer: "hour", Units: "count",
			Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":6}`)},
		})
		for m, v := range []string{`{"qty":1}`, `{"qty":2}`, `{"qty":3}`} {
			ts := at.Add(time.Duration(m) * time.Minute)
			points = append(points, store.IncomingPoint{
				Metric: "step_count", Layer: "minute", Units: "count",
				Point: store.Point{Start: ts, End: ts, Raw: json.RawMessage(v)},
			})
		}
	}
	if _, err := st.Merge(context.Background(), store.Incoming{Points: points},
		store.DeliveryRef{ID: "d"}); err != nil {
		t.Fatalf("слияние: %v", err)
	}

	want := `{"metrics":[{"metric":"step_count","units":["count"],` +
		`"aggregation":{"style":"cumulative","hours":4,"compared":4,"agreeing":4,` +
		`"conflicting":0,"first_hour":"2026-06-01T00:00:00Z","last_hour":"2026-06-01T03:00:00Z"},` +
		`"layers":[{"layer":"hour","from":"2026-06-01T00:00:00Z","to":"2026-06-01T03:00:00Z","points":4},` +
		`{"layer":"minute","from":"2026-06-01T00:00:00Z","to":"2026-06-01T03:02:00Z","points":12}]}]}`

	if got := strings.TrimSpace(getCatalog(t, h, "").Body.String()); got != want {
		t.Errorf("форма ответа изменилась:\n получили %s\n ждали   %s", got, want)
	}
}

// Два запроса подряд на неизменившейся витрине совпадают побайтово: порядок
// метрик и слоёв держится `ORDER BY` в чужом пакете, и снятие сортировки
// «раз индекс и так отсортирован» проявилось бы у клиента, а не в тестах.
func TestКаталогПовторяетсяПобайтово(t *testing.T) {
	h, st, _ := newAPITokens(t, nil, nil)

	base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
	var points []store.IncomingPoint
	for i, metric := range []string{"step_count", "heart_rate", "active_energy"} {
		for _, layer := range []string{"minute", "hour", "raw"} {
			at := base.Add(time.Duration(i) * time.Hour)
			points = append(points, store.IncomingPoint{
				Metric: metric, Layer: layer, Units: "count",
				Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
			})
		}
	}
	if _, err := st.Merge(context.Background(), store.Incoming{Points: points},
		store.DeliveryRef{ID: "d"}); err != nil {
		t.Fatalf("слияние: %v", err)
	}

	first := getCatalog(t, h, "").Body.String()
	second := getCatalog(t, h, "").Body.String()
	if first != second {
		t.Errorf("два ответа на неизменившейся витрине разошлись:\n %s\n %s", first, second)
	}
	if !strings.Contains(first, `"metric":"active_energy"`) {
		t.Fatalf("в ответе нет ожидаемых метрик: %s", first)
	}
}

// Отказ хранилища переводится в 500 с человекочитаемым сообщением: текст ошибки
// наружу не уходит — в нём имена колонок и форма запроса.
//
// Тело отказа сравнивается БАЙТАМИ: у читающего маршрута это такая же часть
// публичного контракта, как успешный ответ, и клиент видит его чаще. Проверка
// «внутренностей не видно» осталась рядом, но она слабее: подстрок в ответе нет
// и у тела, которое переименовало ключ `error`.
func TestКаталогОтвечает500НаОтказХранилища(t *testing.T) {
	h, st, _ := newAPITokens(t, nil, nil)
	if err := st.Close(); err != nil {
		t.Fatalf("закрытие базы: %v", err)
	}

	rec := getCatalog(t, h, "")
	if rec.Code != http.StatusInternalServerError {
		t.Fatalf("статус %d, ждали 500", rec.Code)
	}
	if got := strings.TrimSpace(rec.Body.String()); got != `{"error":"каталог не собрался"}` {
		t.Errorf("форма тела отказа изменилась: %s", got)
	}
}

// Форма тела отказа закреплена байтами и на 401 — том коде, который потребитель
// видит первым, если ошибся токеном.
func TestКаталогОтдаётОжидаемыеБайтыОтказа(t *testing.T) {
	h, _, _ := newAPITokens(t, nil, []string{"read-token"})

	rec := getCatalog(t, h, "")
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("статус %d, ждали 401", rec.Code)
	}
	if got := strings.TrimSpace(rec.Body.String()); got != `{"error":"неверный или отсутствующий токен"}` {
		t.Errorf("форма тела отказа изменилась: %s", got)
	}
}
