package httpapi_test

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

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

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

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

func point(t *testing.T, st *store.Store, metric string, at time.Time) {
	t.Helper()

	_, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{
		Metric: metric, Layer: "minute", Units: "count",
		Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
	}}}, store.DeliveryRef{ID: "d"})
	if err != nil {
		t.Fatalf("слияние: %v", err)
	}
}

// Обещание клиенту целиком: на неизменившейся витрине метка та же и байты те
// же, а повтор с этой меткой не собирает снимка вовсе.
func TestКаталогПовторНаНеизменившейсяВитрине(t *testing.T) {
	h, st, _ := newAPITokens(t, nil, nil)
	point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))

	first := getCatalog(t, h, "")
	tag := first.Header().Get("ETag")
	if tag == "" {
		t.Fatal("ответ ушёл без метки")
	}
	if got := first.Header().Get("Cache-Control"); got != "private, no-cache" {
		t.Errorf("Cache-Control %q — выгрузку здоровья вправе сохранить любой посредник", got)
	}

	second := getCatalog(t, h, "")
	if got := second.Header().Get("ETag"); got != tag {
		t.Errorf("метка сдвинулась на неизменившейся витрине: %q → %q", tag, got)
	}
	if first.Body.String() != second.Body.String() {
		t.Error("два ответа на неизменившейся витрине разошлись байтами")
	}

	cond := conditionalGet(t, h, "", tag)
	if cond.Code != http.StatusNotModified {
		t.Fatalf("статус %d, ждали 304: %s", cond.Code, cond.Body.String())
	}
	if cond.Body.Len() != 0 {
		t.Errorf("у 304 есть тело: %q", cond.Body.String())
	}
	if got := cond.Header().Get("ETag"); got != tag {
		t.Errorf("метка на 304 — %q, ждали %q", got, tag)
	}
	if got := cond.Header().Get("Content-Type"); got != "" {
		t.Errorf("у 304 остались представленческие заголовки: Content-Type=%q", got)
	}
}

// Свёртка записала объект — метка обязана смениться, иначе клиент останется на
// устаревшем ответе навсегда.
func TestКаталогМенялсяПослеЗаписи(t *testing.T) {
	h, st, _ := newAPITokens(t, nil, nil)
	at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
	point(t, st, "step_count", at)

	tag := getCatalog(t, h, "").Header().Get("ETag")
	point(t, st, "heart_rate", at)

	rec := conditionalGet(t, h, "", tag)
	if rec.Code != http.StatusOK {
		t.Fatalf("статус %d, ждали 200 — витрина изменилась", rec.Code)
	}
	if got := rec.Header().Get("ETag"); got == tag {
		t.Error("метка не изменилась после записи в витрину")
	}
}

// Звёздочка совпадает с любой существующей меткой; мусор условия не выполняет
// и отказом не является.
func TestКаталогФормыУсловия(t *testing.T) {
	h, st, _ := newAPITokens(t, nil, nil)
	point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))

	cases := []struct {
		name string
		cond string
		want int
	}{
		{"звёздочка", "*", http.StatusNotModified},
		{"мусор", "не-метка", http.StatusOK},
		{"чужая метка", `W/"gen-нет"`, http.StatusOK},
	}
	for _, c := range cases {
		if got := conditionalGet(t, h, "", c.cond).Code; got != c.want {
			t.Errorf("%s: статус %d, ждали %d", c.name, got, c.want)
		}
	}
}

// Токен проверяется РАНЬШЕ условия: `304` без токена подтверждал бы состояние
// витрины тому, кому она не открыта.
func TestУсловныйЗапросБезТокена(t *testing.T) {
	read := []string{"read-token"}
	h, st, _ := newAPITokens(t, nil, read)
	point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))

	tag := conditionalGet(t, h, "Bearer read-token", "").Header().Get("ETag")
	if tag == "" {
		t.Fatal("ответ ушёл без метки")
	}

	for _, cond := range []string{tag, "*"} {
		if got := conditionalGet(t, h, "", cond).Code; got != http.StatusUnauthorized {
			t.Errorf("условие %q без токена дало статус %d, ждали 401", cond, got)
		}
	}
}

// Смысл условного запроса — в том, что снимок не открывается вовсе. Оракул
// внешний: каталог при сборке пишет владельцу предупреждение о данных из
// будущего, и его отсутствие означает, что сборки не было.
func TestУсловныйОтветНеСобираетКаталог(t *testing.T) {
	h, st, _, seen := newAPILogged(t, nil, nil)
	// Метка из будущего: при каждой сборке каталога она даёт `WARN`.
	point(t, st, "step_count", time.Now().UTC().Add(48*time.Hour))

	tag := getCatalog(t, h, "").Header().Get("ETag")
	if !seen.has("future data") {
		t.Fatal("сборка каталога не дала ожидаемого предупреждения — оракул непригоден")
	}

	seen.reset()
	if got := conditionalGet(t, h, "", tag).Code; got != http.StatusNotModified {
		t.Fatalf("статус %d, ждали 304", got)
	}
	if seen.has("future data") {
		t.Error("условный ответ собрал каталог: снимок открыт зря")
	}
}
