package httpapi_test

import (
	"bytes"
	"compress/gzip"
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

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

const samplePayload = `{"data":{"metrics":[{"name":"step_count","units":"count","data":[{"qty":8,"date":"2026-07-31 12:00:00 +0300"}]}],"workouts":[]}}`

func TestIngestAcceptsPayload(t *testing.T) {
	h, st := newAPI(t, nil)

	req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("automation-name", "healthlog")
	req.Header.Set("automation-aggregation", "None")
	req.Header.Set("session-id", "sess-1")

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("статус = %d, тело = %s", rec.Code, rec.Body.String())
	}

	var resp struct {
		DeliveryID string `json:"delivery_id"`
		Bytes      int64  `json:"bytes"`
		SHA256     string `json:"sha256"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
		t.Fatalf("разбор ответа: %v", err)
	}
	if resp.DeliveryID == "" || resp.SHA256 == "" {
		t.Errorf("ответ без идентификатора или хеша: %+v", resp)
	}
	if resp.Bytes != int64(len(samplePayload)) {
		t.Errorf("bytes = %d, ожидалось %d", resp.Bytes, len(samplePayload))
	}

	if n, err := st.CountDeliveries(t.Context()); err != nil || n != 1 {
		t.Errorf("доставок = %d (err=%v), ожидалась 1", n, err)
	}
}

// Заголовки сохраняются целиком: документация HAE неполна, и незадокументированные
// поля уже приносили самые полезные находки.
func TestIngestStoresAllHeaders(t *testing.T) {
	h, st := newAPI(t, nil)

	req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
	req.Header.Set("automation-id", "BC99C8A3-8BE7-4519-B545-F3ED6212008E")
	req.Header.Set("automation-aggregation", "Minutes")
	req.Header.Set("x-unknown-future-header", "нечто новое")

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("статус = %d", rec.Code)
	}

	d, err := st.LastDelivery(t.Context())
	if err != nil {
		t.Fatalf("LastDelivery: %v", err)
	}

	var got map[string][]string
	if err := json.Unmarshal([]byte(d.Headers), &got); err != nil {
		t.Fatalf("разбор headers: %v (%q)", err, d.Headers)
	}
	for name, want := range map[string]string{
		"Automation-Id":           "BC99C8A3-8BE7-4519-B545-F3ED6212008E",
		"Automation-Aggregation":  "Minutes",
		"X-Unknown-Future-Header": "нечто новое",
	} {
		if len(got[name]) != 1 || got[name][0] != want {
			t.Errorf("заголовок %s = %v, ожидалось [%q]", name, got[name], want)
		}
	}
	if got["Host"] == nil {
		t.Error("Host не сохранён")
	}
}

// Секреты в БД не попадают — ни по имени заголовка, ни по совпадению значения
// с настроенным токеном (токен можно положить в заголовок с любым именем).
func TestIngestRedactsSecretHeaders(t *testing.T) {
	const token = "очень-секретный-токен"
	h, st := newAPI(t, []string{token})

	req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("X-Api-Key", "ключ-метабазы")
	req.Header.Set("X-Custom-Auth", token)

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("статус = %d, тело %s", rec.Code, rec.Body.String())
	}

	d, err := st.LastDelivery(t.Context())
	if err != nil {
		t.Fatalf("LastDelivery: %v", err)
	}
	if strings.Contains(d.Headers, token) {
		t.Errorf("токен утёк в headers: %s", d.Headers)
	}
	if strings.Contains(d.Headers, "ключ-метабазы") {
		t.Errorf("значение X-Api-Key утекло: %s", d.Headers)
	}
}

func TestIngestAcceptsGzip(t *testing.T) {
	h, st := newAPI(t, nil)

	var buf bytes.Buffer
	gz := gzip.NewWriter(&buf)
	if _, err := gz.Write([]byte(samplePayload)); err != nil {
		t.Fatalf("подготовка gzip: %v", err)
	}
	if err := gz.Close(); err != nil {
		t.Fatalf("подготовка gzip: %v", err)
	}

	req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", &buf)
	req.Header.Set("Content-Encoding", "gzip")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("статус = %d, тело = %s", rec.Code, rec.Body.String())
	}
	if n, err := st.CountDeliveries(t.Context()); err != nil || n != 1 {
		t.Errorf("доставок = %d (err=%v), ожидалась 1", n, err)
	}
}

// Битое тело — отказ доставки, о нём отправителю сообщаем.
func TestIngestRejectsMalformed(t *testing.T) {
	h, st := newAPI(t, nil)

	req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(`{"data":{"metrics":[`))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	if rec.Code != http.StatusBadRequest {
		t.Fatalf("статус = %d, ожидался 400", rec.Code)
	}
	if n, _ := st.CountDeliveries(t.Context()); n != 0 { //nolint:errcheck // проверяем только счётчик
		t.Errorf("доставок = %d, ожидалось 0", n)
	}
}

func TestIngestRejectsOversizedBody(t *testing.T) {
	h, _ := newAPI(t, nil)

	// Лимит в тесте — 1 МиБ (см. newAPI), шлём заведомо больше.
	big := `{"data":{"note":"` + strings.Repeat("x", 2<<20) + `"}}`
	req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(big))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	if rec.Code != http.StatusRequestEntityTooLarge {
		t.Fatalf("статус = %d, ожидался 413", rec.Code)
	}
}

// Граница обязана стоять на РАСПАКОВАННОМ теле, а не только на сжатом.
//
// Измерено на прежней версии: 400 КиБ сжатого тела разворачивались в 400 МиБ,
// принимались со статусом 200 и выедали гигабайт. При потолке степени сжатия
// gzip около 1030:1 и штатных 64 МиБ речь о десятках гигабайт на запрос, а
// параллельные складываются. Цена отказа здесь наивысшая в проекте: приём —
// единственное место, где поток существует, и доставка, не попавшая в архив,
// не попадает в журнал вовсе.
func TestIngestRejectsGzipBomb(t *testing.T) {
	h, st := newAPI(t, nil)

	// Лимит в тесте — 1 МиБ (см. newAPI). Тело валидное, просто очень длинное
	// и сжимается почти нацело.
	payload := `{"data":{"metrics":[]},"pad":"` + strings.Repeat(" ", 8<<20) + `"}`

	var buf bytes.Buffer
	gz := gzip.NewWriter(&buf)
	if _, err := gz.Write([]byte(payload)); err != nil {
		t.Fatalf("подготовка gzip: %v", err)
	}
	if err := gz.Close(); err != nil {
		t.Fatalf("подготовка gzip: %v", err)
	}
	if buf.Len() >= 1<<20 {
		t.Fatalf("сжатое тело %d байт — уже больше лимита, тест проверяет не то", buf.Len())
	}

	req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", &buf)
	req.Header.Set("Content-Encoding", "gzip")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	if rec.Code != http.StatusRequestEntityTooLarge {
		t.Fatalf("статус = %d, ожидался 413: распакованное тело больше лимита", rec.Code)
	}
	if n, _ := st.CountDeliveries(t.Context()); n != 0 { //nolint:errcheck // проверяем только счётчик
		t.Errorf("доставок = %d, ожидалось 0", n)
	}
}

func TestIngestChecksTokenWhenConfigured(t *testing.T) {
	h, _ := newAPI(t, []string{"верный-токен"})

	cases := map[string]struct {
		header string
		want   int
	}{
		"без токена":   {"", http.StatusUnauthorized},
		"чужой токен":  {"Bearer чужой", http.StatusUnauthorized},
		"без префикса": {"верный-токен", http.StatusUnauthorized},
		"верный токен": {"Bearer верный-токен", http.StatusOK},
	}

	for name, tc := range cases {
		t.Run(name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
			if tc.header != "" {
				req.Header.Set("Authorization", tc.header)
			}
			rec := httptest.NewRecorder()
			h.ServeHTTP(rec, req)

			if rec.Code != tc.want {
				t.Errorf("статус = %d, ожидался %d", rec.Code, tc.want)
			}
		})
	}
}

func TestHealthz(t *testing.T) {
	h, _ := newAPI(t, nil)

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))

	if rec.Code != http.StatusOK {
		t.Fatalf("статус = %d", rec.Code)
	}
}

// Транспорт, не умеющий дедлайнов, приём не роняет: цена отказа здесь наивысшая
// в проекте — доставка, не попавшая в архив, не попадает и в журнал.
func TestПриёмРаботаетНаТранспортеБезДедлайнов(t *testing.T) {
	h, st := newAPI(t, nil)

	req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest",
		strings.NewReader(`{"data":{"metrics":[]}}`))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("статус = %d, ожидался 200", rec.Code)
	}
	n, err := st.CountDeliveries(context.Background())
	if err != nil {
		t.Fatalf("CountDeliveries: %v", err)
	}
	if n != 1 {
		t.Errorf("доставок в учёте %d, ожидалась 1", n)
	}
}

func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
	t.Helper()

	h, st, _ := newAPITokens(t, writeTokens, nil)
	return h, st
}

// newAPITokens собирает роутер с обоими контурами. Отдельно от newAPI, чтобы не
// переписывать два десятка вызовов ради одного параметра.
func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service) {
	t.Helper()

	h, st, cat, _ := newAPILogged(t, writeTokens, readTokens)
	return h, st, cat
}

// newAPILogged отдаёт ещё и записи лога. Нужен там, где лог служит ОРАКУЛОМ, а
// не наблюдением: единственный внешний признак того, что каталог собирался, —
// его предупреждения владельцу.
func newAPILogged(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service, *records) {
	t.Helper()
	dir := t.TempDir()

	st, err := store.Open(filepath.Join(dir, "healthlog.db"))
	if err != nil {
		t.Fatalf("store.Open: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })

	arch, err := archive.New(filepath.Join(dir, "raw"))
	if err != nil {
		t.Fatalf("archive.New: %v", err)
	}

	seen := &records{}
	log := slog.New(seen)
	cat := catalog.New(st, log)
	h := httpapi.New(httpapi.Options{
		Ingest:      ingest.New(arch, st, nil, log),
		Catalog:     cat,
		Points:      points.New(st, log),
		Log:         log,
		WriteTokens: writeTokens,
		ReadTokens:  readTokens,
		MaxBodyMB:   1,
		// Бюджет задаётся всегда: httptest.ResponseRecorder дедлайнов не умеет,
		// и это ровно тот транспорт, на котором приём обязан продолжать работать.
		IngestWriteBudget: time.Minute,
	})
	return h, st, cat, seen
}

// records — slog.Handler, копящий сообщения. Значений атрибутов не хранит:
// проверяется факт записи, а данные о здоровье в тесты тащить незачем.
type records struct {
	mu  sync.Mutex
	msg []string
}

func (r *records) Enabled(context.Context, slog.Level) bool { return true }

func (r *records) Handle(_ context.Context, rec slog.Record) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.msg = append(r.msg, rec.Message)
	return nil
}

func (r *records) WithAttrs([]slog.Attr) slog.Handler { return r }
func (r *records) WithGroup(string) slog.Handler      { return r }

func (r *records) has(msg string) bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	for _, m := range r.msg {
		if m == msg {
			return true
		}
	}
	return false
}

func (r *records) reset() {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.msg = nil
}
