package activityhub_client

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"reflect"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"go.opentelemetry.io/otel/trace"
)

func TestSearchActivitiesBuildsSafeRequestAndPreservesPayloadNumbers(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if got, want := r.URL.EscapedPath(), "/api/v1/search/cluster%2Fwith%20spaces/from/100/to/200"; got != want {
			t.Errorf("escaped path = %q, want %q", got, want)
		}
		if got := r.URL.Query().Get("catalog"); got != "diagnosis & events" {
			t.Errorf("catalog = %q", got)
		}
		if got := r.URL.Query().Get("name"); got != "slow query?" {
			t.Errorf("name = %q", got)
		}
		if got := r.Header.Get(activityHubSourceHeader); got != "clinic" {
			t.Errorf("source header = %q", got)
		}
		if got := r.Header.Get(activityHubKeyHeader); got != "secret" {
			t.Errorf("key header = %q", got)
		}
		if got := r.Header.Get(activityHubCursorHeader); got != "next/page" {
			t.Errorf("cursor header = %q", got)
		}
		if got := r.Header.Get("X-Trace-ID"); got != "11111111111111111111111111111111" {
			t.Errorf("trace ID header = %q", got)
		}
		_, _ = w.Write([]byte(`{
			"result":[{
				"type":"event","name":"slow-query","severity":2,
				"calibration_time":1700000000,
				"payload":{"large_integer":9007199254740993}
			}],
			"partial":true,"cursor":"cursor-2","status":"success"
		}`))
	}))
	defer server.Close()

	client := NewClient(server.URL+"/api/v1/", "clinic", "secret")
	client.client = server.Client()
	spanContext := trace.NewSpanContext(trace.SpanContextConfig{
		TraceID: trace.TraceID{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11},
		SpanID:  trace.SpanID{1},
	})
	ctx := trace.ContextWithSpanContext(context.Background(), spanContext)
	got, err := client.SearchActivities(ctx, "cluster/with spaces", SearchParams{
		BeginTS:  100,
		EndTS:    200,
		Catalog:  "diagnosis & events",
		Name:     "slow query?",
		Severity: 2,
		Limit:    25,
		Cursor:   "next/page",
	})
	if err != nil {
		t.Fatalf("SearchActivities() error = %v", err)
	}
	if !got.Partial || got.Cursor != "cursor-2" || len(got.Activities) != 1 {
		t.Fatalf("SearchActivities() = %#v", got)
	}
	payload, ok := got.Activities[0].Payload.(map[string]any)
	if !ok {
		t.Fatalf("payload type = %T", got.Activities[0].Payload)
	}
	if number, ok := payload["large_integer"].(json.Number); !ok || number.String() != "9007199254740993" {
		t.Fatalf("large_integer = %#v (%T)", payload["large_integer"], payload["large_integer"])
	}
}

func TestSearchActivitiesNormalizesOnlyExactNotFoundResponse(t *testing.T) {
	t.Parallel()

	for _, status := range []int{http.StatusOK, http.StatusNotFound} {
		status := status
		t.Run(http.StatusText(status), func(t *testing.T) {
			t.Parallel()
			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				w.WriteHeader(status)
				_, _ = w.Write([]byte(`{"status":"error","msg":"Activity not found.","result":null}`))
			}))
			defer server.Close()

			client := NewClient(server.URL, "clinic", "secret")
			client.client = server.Client()
			got, err := client.SearchActivities(context.Background(), "cluster-1", SearchParams{})
			if err != nil {
				t.Fatalf("SearchActivities() error = %v", err)
			}
			if got.Activities == nil || len(got.Activities) != 0 {
				t.Fatalf("activities = %#v, want []", got.Activities)
			}
			encoded, err := json.Marshal(got)
			if err != nil {
				t.Fatal(err)
			}
			if string(encoded) != `{"activities":[],"partial":false,"cursor":""}` {
				t.Fatalf("response = %s", encoded)
			}
		})
	}

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNotFound)
		_, _ = w.Write([]byte(`{"status":"error","msg":"sensitive detail"}`))
	}))
	defer server.Close()
	client := NewClient(server.URL, "clinic", "secret")
	client.client = server.Client()
	_, err := client.SearchActivities(context.Background(), "cluster-1", SearchParams{})
	var upstreamError *UpstreamError
	if !errors.As(err, &upstreamError) || upstreamError.StatusCode != http.StatusNotFound {
		t.Fatalf("error = %v, want sanitized 404", err)
	}
	if strings.Contains(err.Error(), "sensitive detail") {
		t.Fatalf("error leaks upstream body: %q", err)
	}
}

func TestSearchActivitiesPreservesSafeUpstreamRequestIdentifiers(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("x-request-id", "activityhub-request-1   ignored")
		w.Header().Set("x-amzn-requestid", "api-gateway-request-1")
		w.Header().Set("x-amz-apigw-id", "api-gateway-id-1")
		w.WriteHeader(http.StatusNotFound)
		_, _ = w.Write([]byte(`{"status":"error","msg":"target cluster-1 failed\nretry later","payload":{"secret":"must-not-be-retained"}}`))
	}))
	defer server.Close()

	client := NewClient(server.URL, "clinic", "secret")
	client.client = server.Client()
	_, err := client.SearchActivities(context.Background(), "cluster-1", SearchParams{})
	var upstreamError *UpstreamError
	if !errors.As(err, &upstreamError) {
		t.Fatalf("error = %v, want UpstreamError", err)
	}
	if upstreamError.StatusCode != http.StatusNotFound {
		t.Errorf("status = %d, want %d", upstreamError.StatusCode, http.StatusNotFound)
	}
	if upstreamError.RequestID != "activityhub-request-1 ignored" {
		t.Errorf("request ID = %q", upstreamError.RequestID)
	}
	if upstreamError.AWSRequestID != "api-gateway-request-1" {
		t.Errorf("AWS request ID = %q", upstreamError.AWSRequestID)
	}
	if upstreamError.APIGatewayID != "api-gateway-id-1" {
		t.Errorf("API Gateway ID = %q", upstreamError.APIGatewayID)
	}
	if strings.Contains(err.Error(), "must-not-be-retained") {
		t.Fatalf("error string leaks upstream content: %q", err)
	}
}

func TestSearchActivitiesConvertsProtocolErrorToTypedUpstreamError(t *testing.T) {
	t.Parallel()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte(`{"status":"error","msg":"temporary backend error"}`))
	}))
	defer server.Close()

	client := NewClient(server.URL, "clinic", "secret")
	client.client = server.Client()
	_, err := client.SearchActivities(context.Background(), "cluster-1", SearchParams{})
	var upstreamError *UpstreamError
	if !errors.As(err, &upstreamError) {
		t.Fatalf("error = %v, want UpstreamError", err)
	}
	if upstreamError.StatusCode != http.StatusOK || !upstreamError.ProtocolError {
		t.Fatalf("upstream error = %#v", upstreamError)
	}
}

func TestNormalizeDiagnosticIDBoundsAndNormalizes(t *testing.T) {
	t.Parallel()

	got := normalizeDiagnosticID("  first\n\tsecond  " + strings.Repeat("界", maxDiagnosticIDRunes))
	if !strings.HasPrefix(got, "first second ") {
		t.Fatalf("normalized text = %q", got)
	}
	if len([]rune(got)) != maxDiagnosticIDRunes {
		t.Fatalf("normalized rune count = %d, want %d", len([]rune(got)), maxDiagnosticIDRunes)
	}
}

func TestSearchActivitiesRejectsRedirectAndTrailingJSON(t *testing.T) {
	t.Parallel()

	var redirected atomic.Bool
	target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
		redirected.Store(true)
	}))
	defer target.Close()
	redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Location", target.URL)
		w.WriteHeader(http.StatusFound)
	}))
	defer redirector.Close()

	client := NewClient(redirector.URL, "clinic", "secret")
	client.client = redirector.Client()
	_, err := client.SearchActivities(context.Background(), "cluster-1", SearchParams{})
	var upstreamError *UpstreamError
	if !errors.As(err, &upstreamError) || upstreamError.StatusCode != http.StatusFound {
		t.Fatalf("error = %v, want HTTP 302", err)
	}
	if redirected.Load() {
		t.Fatal("client followed redirect with ActivityHub credentials")
	}

	trailing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte(`{"result":[]} {"unexpected":true}`))
	}))
	defer trailing.Close()
	client = NewClient(trailing.URL, "clinic", "secret")
	client.client = trailing.Client()
	if _, err := client.SearchActivities(context.Background(), "cluster-1", SearchParams{}); err == nil {
		t.Fatal("SearchActivities() error = nil, want trailing JSON error")
	}
}

func TestSearchActivitiesByTimeRangePreservesLegacyTransportAndDecodePolicy(t *testing.T) {
	t.Parallel()

	var redirected atomic.Bool
	target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		redirected.Store(true)
		w.WriteHeader(http.StatusBadGateway)
		_, _ = w.Write([]byte(`{"status":"error","msg":"Activity not found.","result":null} {"trailing":true}`))
	}))
	defer target.Close()
	redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Location", target.URL)
		w.WriteHeader(http.StatusFound)
	}))
	defer redirector.Close()

	client := NewClient(redirector.URL, "clinic", "secret")
	client.client = redirector.Client()
	activities, partial, cursor, err := client.SearchActivitiesByTimeRange(
		"cluster-1", 100, 200, 20, "", &Activity{},
	)
	if err != nil {
		t.Fatalf("SearchActivitiesByTimeRange() error = %v", err)
	}
	if !redirected.Load() {
		t.Fatal("legacy client did not follow redirect")
	}
	if activities != nil || partial || cursor != "" {
		t.Fatalf("legacy result = (%#v, %v, %q), want (nil, false, empty)", activities, partial, cursor)
	}
}

func TestSearchActivitiesHonorsContextAndBodyLimit(t *testing.T) {
	t.Parallel()

	started := make(chan struct{})
	server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
		close(started)
		<-r.Context().Done()
	}))
	defer server.Close()
	client := NewClient(server.URL, "clinic", "secret")
	client.client = server.Client()
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan error, 1)
	go func() {
		_, err := client.SearchActivities(ctx, "cluster-1", SearchParams{})
		done <- err
	}()
	select {
	case <-started:
	case <-time.After(time.Second):
		t.Fatal("upstream request did not start")
	}
	cancel()
	select {
	case err := <-done:
		if !errors.Is(err, context.Canceled) {
			t.Fatalf("error = %v, want context.Canceled", err)
		}
	case <-time.After(time.Second):
		t.Fatal("request did not stop after cancellation")
	}

	content, err := readSearchResponseBody(strings.NewReader(strings.Repeat("x", maxResponseBodyBytes+1)))
	if err == nil || content != nil {
		t.Fatalf("readSearchResponseBody() = (%d bytes, %v), want size error", len(content), err)
	}
}

func TestToSearchActivitiesSkipsNullEntriesAndReturnsNonNilSlice(t *testing.T) {
	t.Parallel()

	got := toSearchActivities([]*activity{nil, {Name: "one"}})
	want := []*Activity{{Name: "one", Context: nil}}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("toSearchActivities() = %#v, want %#v", got, want)
	}
	if empty := toSearchActivities(nil); empty == nil {
		t.Fatal("toSearchActivities(nil) = nil, want non-nil empty slice")
	}
}

func TestToActivitiesPreservesLegacyNilSlice(t *testing.T) {
	t.Parallel()

	if got := toActivities(nil); got != nil {
		t.Fatalf("toActivities(nil) = %#v, want nil", got)
	}
}
