package main

import (
	"context"
	"errors"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/prometheus/client_golang/prometheus"
)

type fakeStore struct{ pingErr error }

const (
	testBuoyID    = "00000000-0000-4000-8000-000000000001"
	missingBuoyID = "00000000-0000-4000-8000-000000000002"
)

func (f *fakeStore) Ping(context.Context) error    { return f.pingErr }
func (f *fakeStore) Migrate(context.Context) error { return nil }
func (f *fakeStore) Close()                        {}
func (f *fakeStore) ListBuoys(context.Context) ([]buoy, error) {
	return []buoy{{ID: testBuoyID, Name: "Atlantic"}}, nil
}
func (f *fakeStore) CreateBuoy(_ context.Context, b buoy) (buoy, error) {
	b.CreatedAt = time.Unix(1, 0)
	return b, nil
}
func (f *fakeStore) GetBuoy(_ context.Context, id string) (buoy, error) {
	if id == missingBuoyID {
		return buoy{}, pgx.ErrNoRows
	}
	return buoy{ID: id}, nil
}
func (f *fakeStore) DeleteBuoy(_ context.Context, id string) error {
	if id == missingBuoyID {
		return pgx.ErrNoRows
	}
	return nil
}
func (f *fakeStore) ListReadings(context.Context, string, *time.Time, *time.Time, int, int) ([]reading, error) {
	return []reading{}, nil
}
func (f *fakeStore) CreateReading(_ context.Context, r reading) (reading, error) {
	r.RecordedAt = time.Unix(1, 0)
	return r, nil
}
func (f *fakeStore) AggregateReadings(context.Context, time.Duration) ([]aggregate, error) {
	return []aggregate{}, nil
}

type failingStore struct {
	fakeStore
	operation string
}

var errDatabaseUnavailable = errors.New("database unavailable")

func (f *failingStore) ListBuoys(context.Context) ([]buoy, error) {
	if f.operation == "list buoys" {
		return nil, errDatabaseUnavailable
	}
	return f.fakeStore.ListBuoys(context.Background())
}
func (f *failingStore) CreateBuoy(ctx context.Context, b buoy) (buoy, error) {
	if f.operation == "create buoy" {
		return buoy{}, errDatabaseUnavailable
	}
	return f.fakeStore.CreateBuoy(ctx, b)
}
func (f *failingStore) GetBuoy(ctx context.Context, id string) (buoy, error) {
	if f.operation == "get buoy" {
		return buoy{}, errDatabaseUnavailable
	}
	return f.fakeStore.GetBuoy(ctx, id)
}
func (f *failingStore) DeleteBuoy(ctx context.Context, id string) error {
	if f.operation == "delete buoy" {
		return errDatabaseUnavailable
	}
	return f.fakeStore.DeleteBuoy(ctx, id)
}
func (f *failingStore) ListReadings(ctx context.Context, buoyID string, from, to *time.Time, limit, offset int) ([]reading, error) {
	if f.operation == "list readings" {
		return nil, errDatabaseUnavailable
	}
	return f.fakeStore.ListReadings(ctx, buoyID, from, to, limit, offset)
}
func (f *failingStore) CreateReading(ctx context.Context, r reading) (reading, error) {
	if f.operation == "create reading" {
		return reading{}, errDatabaseUnavailable
	}
	return f.fakeStore.CreateReading(ctx, r)
}
func (f *failingStore) AggregateReadings(ctx context.Context, window time.Duration) ([]aggregate, error) {
	if f.operation == "aggregate readings" {
		return nil, errDatabaseUnavailable
	}
	return f.fakeStore.AggregateReadings(ctx, window)
}

func testAPI(s store, chaos float64) http.Handler {
	handler, _ := testAPIWithMetrics(s, chaos)
	return handler
}

func testAPIWithMetrics(s store, chaos float64) (http.Handler, *metrics) {
	reg := prometheus.NewRegistry()
	ready := &atomic.Bool{}
	ready.Store(true)
	m := newMetrics(reg)
	a := &api{store: s, metrics: m, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), version: "test", chaosRate: chaos, migrated: ready, prometheus: reg}
	return a.routes(), m
}

func TestHandlers(t *testing.T) {
	tests := []struct {
		name, method, path, body string
		want                     int
	}{
		{"health", http.MethodGet, "/healthz", "", http.StatusOK},
		{"ready", http.MethodGet, "/readyz", "", http.StatusOK},
		{"metrics", http.MethodGet, "/metrics", "", http.StatusOK},
		{"list buoys", http.MethodGet, "/api/v1/buoys", "", http.StatusOK},
		{"create buoy", http.MethodPost, "/api/v1/buoys", `{"name":"North","latitude":42,"longitude":-8}`, http.StatusCreated},
		{"invalid buoy", http.MethodPost, "/api/v1/buoys", `{"name":"","latitude":42,"longitude":-8}`, http.StatusBadRequest},
		{"get buoy", http.MethodGet, "/api/v1/buoys/" + testBuoyID, "", http.StatusOK},
		{"missing buoy", http.MethodGet, "/api/v1/buoys/" + missingBuoyID, "", http.StatusNotFound},
		{"invalid buoy ID", http.MethodGet, "/api/v1/buoys/not-a-uuid", "", http.StatusBadRequest},
		{"delete buoy", http.MethodDelete, "/api/v1/buoys/" + testBuoyID, "", http.StatusNoContent},
		{"list readings", http.MethodGet, "/api/v1/readings?limit=10", "", http.StatusOK},
		{"bad timestamp", http.MethodGet, "/api/v1/readings?from=yesterday", "", http.StatusBadRequest},
		{"create reading", http.MethodPost, "/api/v1/readings", `{"buoy_id":"` + testBuoyID + `","water_temp":14,"wave_height":2}`, http.StatusCreated},
		{"aggregate", http.MethodGet, "/api/v1/readings/aggregate?window=1h", "", http.StatusOK},
		{"bad aggregate", http.MethodGet, "/api/v1/readings/aggregate?window=bad", "", http.StatusBadRequest},
	}
	handler := testAPI(&fakeStore{}, 0)
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
			recorder := httptest.NewRecorder()
			handler.ServeHTTP(recorder, req)
			if recorder.Code != tt.want {
				t.Fatalf("status = %d, want %d; body=%s", recorder.Code, tt.want, recorder.Body.String())
			}
			if got := recorder.Header().Get("X-Nereus-Version"); got != "test" {
				t.Errorf("X-Nereus-Version = %q, want test", got)
			}
		})
	}
}

func TestReadinessFailure(t *testing.T) {
	recorder := httptest.NewRecorder()
	testAPI(&fakeStore{pingErr: context.DeadlineExceeded}, 0).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
	if recorder.Code != http.StatusServiceUnavailable {
		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
	}
}

func TestChaosMiddleware(t *testing.T) {
	recorder := httptest.NewRecorder()
	testAPI(&fakeStore{}, 1).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/buoys", nil))
	if recorder.Code != http.StatusInternalServerError {
		t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError)
	}
	recorder = httptest.NewRecorder()
	testAPI(&fakeStore{}, 1).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
	if recorder.Code != http.StatusOK {
		t.Fatalf("health status = %d, want %d", recorder.Code, http.StatusOK)
	}
}

func TestDataEndpointsReturnInternalErrorOnDatabaseFailure(t *testing.T) {
	tests := []struct {
		name, method, path, body string
	}{
		{"list buoys", http.MethodGet, "/api/v1/buoys", ""},
		{"create buoy", http.MethodPost, "/api/v1/buoys", `{"name":"North","latitude":42,"longitude":-8}`},
		{"get buoy", http.MethodGet, "/api/v1/buoys/" + testBuoyID, ""},
		{"delete buoy", http.MethodDelete, "/api/v1/buoys/" + testBuoyID, ""},
		{"list readings", http.MethodGet, "/api/v1/readings", ""},
		{"create reading", http.MethodPost, "/api/v1/readings", `{"buoy_id":"` + testBuoyID + `","water_temp":14,"wave_height":2}`},
		{"aggregate readings", http.MethodGet, "/api/v1/readings/aggregate?window=1h", ""},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			recorder := httptest.NewRecorder()
			testAPI(&failingStore{operation: tt.name}, 0).ServeHTTP(recorder, httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body)))
			if recorder.Code != http.StatusInternalServerError {
				t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusInternalServerError, recorder.Body.String())
			}
			if recorder.Body.String() != "{\"error\":\"internal error\"}\n" {
				t.Fatalf("body = %q", recorder.Body.String())
			}
		})
	}
}

func TestJSONBodyValidation(t *testing.T) {
	tests := []struct {
		name, body string
	}{
		{"malformed", `{"name":`},
		{"multiple values", `{"name":"North","latitude":42,"longitude":-8} {}`},
		{"oversized", `{"name":"` + strings.Repeat("x", 1<<20) + `","latitude":42,"longitude":-8}`},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			recorder := httptest.NewRecorder()
			testAPI(&fakeStore{}, 0).ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/buoys", strings.NewReader(tt.body)))
			if recorder.Code != http.StatusBadRequest {
				t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest)
			}
		})
	}
}

func TestMetricNamesAndRouteTemplateLabels(t *testing.T) {
	handler, metrics := testAPIWithMetrics(&fakeStore{}, 0)
	metrics.db.WithLabelValues("test").Observe(0)

	recorder := httptest.NewRecorder()
	handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/buoys/"+testBuoyID, nil))
	recorder = httptest.NewRecorder()
	handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil))
	body := recorder.Body.String()

	for _, name := range []string{
		"nereus_http_requests_total",
		"nereus_http_request_duration_seconds",
		"nereus_db_query_duration_seconds",
		"nereus_readings_ingested_total",
		"nereus_buoys_active",
	} {
		if !strings.Contains(body, "# HELP "+name+" ") {
			t.Errorf("metrics output does not contain %q", name)
		}
	}
	if !strings.Contains(body, `nereus_http_requests_total{method="GET",path="/api/v1/buoys/{id}",status="200",version="test"} 1`) {
		t.Error("request counter does not contain the exact labels and buoy route template")
	}
	if !strings.Contains(body, `nereus_http_request_duration_seconds_count{method="GET",path="/api/v1/buoys/{id}",version="test"} 1`) {
		t.Error("request histogram does not contain the exact labels and buoy route template")
	}
	if strings.Contains(body, `path="/api/v1/buoys/`+testBuoyID+`"`) {
		t.Error("metrics output contains a resolved resource path")
	}
}
