// This file verifies deterministic O11Y acquisition, quality checks, archives, and cache reuse.
package evaluation

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

const testBearerToken = "test-token"

func TestFetchO11YWritesSingleArchiveAndDerivedCase(t *testing.T) {
	decisionAt := time.Date(2026, 6, 13, 13, 35, 0, 0, time.UTC)
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if got := r.Header.Get("Authorization"); got != "Bearer "+testBearerToken {
			t.Errorf("Authorization = %q, want bearer token", got)
		}

		switch r.URL.Path {
		case "/application/api/v1/query_range":
			if got := r.URL.Query().Get("query"); got != "app query" {
				t.Errorf("query = %q, want app query", got)
			}
			if got := r.URL.Query().Get("step"); got != "60" {
				t.Errorf("step = %q, want 60", got)
			}
			fmt.Fprintf(w, `{
  "status": "success",
  "isPartial": false,
  "data": {
    "resultType": "matrix",
    "result": [
      {"metric": {"instance": "raw-b"}, "values": [[1781357640, "0.4"], [1781357760, "0.9"]]},
      {"metric": {"instance": "raw-a"}, "values": [[1781357640, "0.3"], [1781357760, "0.8"]]}
    ]
  }
}`)
		case "/infrastructure/api/v1/query":
			if got := r.URL.Query().Get("time"); got != "1781357700" {
				t.Errorf("time = %q, want decision timestamp", got)
			}
			fmt.Fprintf(w, `{
  "status": "success",
  "isPartial": false,
  "data": {
    "resultType": "vector",
    "result": [
      {"metric": {"metric_name": "memory", "internal": "drop-me"}, "value": [1781357700, "67"]}
    ]
  }
}`)
		default:
			http.NotFound(w, r)
		}
	}))
	t.Cleanup(server.Close)

	spec := validO11YExportSpec(server.URL, decisionAt)
	outputDir := filepath.Join(t.TempDir(), "export")
	archivePath := filepath.Join(outputDir, "tidb", spec.Case.ID+".json")
	casePath := filepath.Join(outputDir, "derived", spec.Case.ID+".json")
	bundle, archive, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
		Client:      server.Client(),
		BearerToken: testBearerToken,
		ArchivePath: archivePath,
		CasePath:    casePath,
	})
	if err != nil {
		t.Fatalf("FetchO11Y(): %v", err)
	}
	if bundle.Source != DataSourceO11Y {
		t.Fatalf("source = %q, want %q", bundle.Source, DataSourceO11Y)
	}
	if len(bundle.Input.Metrics) != 3 {
		t.Fatalf("metrics = %d, want 3", len(bundle.Input.Metrics))
	}
	if got := bundle.Input.Metrics[0].Labels["instance"]; got != "raw-a" {
		t.Errorf("first instance = %q, want raw-a", got)
	}
	if got := bundle.Input.Metrics[1].Labels["instance"]; got != "raw-b" {
		t.Errorf("second instance = %q, want raw-b", got)
	}
	for _, series := range bundle.Input.Metrics {
		for _, sample := range series.Samples {
			if sample.Timestamp.After(decisionAt) {
				t.Fatalf("sample %s is after decisionAt %s", sample.Timestamp, decisionAt)
			}
		}
	}
	if got := bundle.Input.Metrics[2].Labels; len(got) != 1 || got["metric_name"] != "memory" {
		t.Errorf("instant labels = %v, want metric_name only", got)
	}
	if archive.Quality.IsPartial || len(archive.Quality.MissingSeries) != 0 {
		t.Fatalf("archive quality = %+v, want complete", archive.Quality)
	}
	if len(archive.Queries) != 2 {
		t.Fatalf("archive queries = %d, want 2", len(archive.Queries))
	}
	for i := range archive.Queries {
		if len(archive.Queries[i].Response) == 0 {
			t.Errorf("archive query %q response is empty", archive.Queries[i].Name)
		}
	}

	for _, path := range []string{archivePath, casePath} {
		info, statErr := os.Stat(path)
		if statErr != nil {
			t.Fatalf("stat %s: %v", path, statErr)
		}
		if got := info.Mode().Perm(); got != 0o600 {
			t.Errorf("mode %s = %o, want 600", path, got)
		}
	}
}

func TestFetchO11YReusesCachedResponseWithoutAuth(t *testing.T) {
	decisionAt := time.Date(2026, 6, 13, 13, 35, 0, 0, time.UTC)
	requestCount := 0
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requestCount++
		if requestCount > 1 {
			http.Error(w, "unexpected repeated request", http.StatusInternalServerError)
			return
		}
		if got := r.Header.Get("Authorization"); got != "Bearer "+testBearerToken {
			t.Errorf("Authorization = %q, want bearer token on cache miss", got)
		}
		fmt.Fprintf(w, `{
  "status": "success",
  "isPartial": false,
  "data": {
    "resultType": "matrix",
    "result": [
      {"metric": {"instance": "raw-a"}, "values": [[%d, "0.3"], [%d, "0.4"]]}
    ]
  }
}`, decisionAt.Add(-time.Minute).Unix(), decisionAt.Unix())
	}))
	t.Cleanup(server.Close)

	spec := validO11YExportSpec(server.URL, decisionAt)
	spec.Sources = spec.Sources[:1]
	spec.Sources[0].Queries = spec.Sources[0].Queries[:1]
	cacheDir := filepath.Join(t.TempDir(), "cache")

	_, firstArchive, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
		Client:      server.Client(),
		BearerToken: testBearerToken,
		ArchivePath: filepath.Join(t.TempDir(), "first.json"),
		CacheDir:    cacheDir,
	})
	if err != nil {
		t.Fatalf("first FetchO11Y(): %v", err)
	}
	_, secondArchive, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
		Client:             server.Client(),
		RequireBearerToken: true,
		ArchivePath:        filepath.Join(t.TempDir(), "second.json"),
		CacheDir:           cacheDir,
	})
	if err != nil {
		t.Fatalf("second FetchO11Y(): %v", err)
	}

	if requestCount != 1 {
		t.Fatalf("HTTP requests = %d, want 1", requestCount)
	}
	if firstArchive.Queries[0].CacheHit {
		t.Error("first query cacheHit = true, want false")
	}
	if !secondArchive.Queries[0].CacheHit {
		t.Error("second query cacheHit = false, want true")
	}
	if firstArchive.Queries[0].RequestDigest == "" || firstArchive.Queries[0].RequestDigest != secondArchive.Queries[0].RequestDigest {
		t.Errorf("request digests = %q and %q, want equal non-empty values", firstArchive.Queries[0].RequestDigest, secondArchive.Queries[0].RequestDigest)
	}
	info, err := os.Stat(cacheDir)
	if err != nil {
		t.Fatalf("stat cache directory: %v", err)
	}
	if got := info.Mode().Perm(); got != 0o700 {
		t.Errorf("cache directory mode = %o, want 700", got)
	}
	info, err = os.Stat(filepath.Join(cacheDir, firstArchive.Queries[0].RequestDigest+".json"))
	if err != nil {
		t.Fatalf("stat cached response: %v", err)
	}
	if got := info.Mode().Perm(); got != 0o600 {
		t.Errorf("cached response mode = %o, want 600", got)
	}
}

func TestFetchO11YCacheMissesWhenRequestChanges(t *testing.T) {
	decisionAt := time.Date(2026, 6, 13, 13, 35, 0, 0, time.UTC)
	requestCount := 0
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		requestCount++
		fmt.Fprintf(w, `{
  "status": "success",
  "isPartial": false,
  "data": {
    "resultType": "matrix",
    "result": [
      {"metric": {"instance": "raw-a"}, "values": [[%d, "0.3"], [%d, "0.4"]]}
    ]
  }
}`, decisionAt.Add(-time.Minute).Unix(), decisionAt.Unix())
	}))
	t.Cleanup(server.Close)

	spec := validO11YExportSpec(server.URL, decisionAt)
	spec.Sources = spec.Sources[:1]
	spec.Sources[0].Queries = spec.Sources[0].Queries[:1]
	cacheDir := filepath.Join(t.TempDir(), "cache")

	_, firstArchive, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
		Client:      server.Client(),
		BearerToken: testBearerToken,
		ArchivePath: filepath.Join(t.TempDir(), "first.json"),
		CacheDir:    cacheDir,
	})
	if err != nil {
		t.Fatalf("first FetchO11Y(): %v", err)
	}

	spec.Sources[0].Queries[0].PromQL = "changed app query"
	_, secondArchive, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
		Client:      server.Client(),
		BearerToken: testBearerToken,
		ArchivePath: filepath.Join(t.TempDir(), "second.json"),
		CacheDir:    cacheDir,
	})
	if err != nil {
		t.Fatalf("second FetchO11Y(): %v", err)
	}

	if requestCount != 2 {
		t.Fatalf("HTTP requests = %d, want 2 after request change", requestCount)
	}
	if secondArchive.Queries[0].CacheHit {
		t.Error("changed query cacheHit = true, want false")
	}
	if firstArchive.Queries[0].RequestDigest == secondArchive.Queries[0].RequestDigest {
		t.Errorf("request digest = %q for both requests, want different values", firstArchive.Queries[0].RequestDigest)
	}
}

func TestFetchO11YRejectsPartialResponse(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		fmt.Fprint(w, `{"status":"success","isPartial":true,"data":{"resultType":"matrix","result":[]}}`)
	}))
	t.Cleanup(server.Close)

	spec := validO11YExportSpec(server.URL, time.Date(2026, 6, 13, 13, 35, 0, 0, time.UTC))
	spec.Sources = spec.Sources[:1]
	spec.Sources[0].Queries = spec.Sources[0].Queries[:1]
	outputDir := filepath.Join(t.TempDir(), "export")
	archivePath := filepath.Join(outputDir, "archive.json")
	casePath := filepath.Join(outputDir, "case.json")
	bundle, archive, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
		Client:      server.Client(),
		BearerToken: testBearerToken,
		ArchivePath: archivePath,
		CasePath:    casePath,
	})
	if err == nil || !strings.Contains(err.Error(), "partial") {
		t.Fatalf("FetchO11Y() error = %v, want partial response error", err)
	}
	if bundle != nil {
		t.Fatalf("bundle = %+v, want nil", bundle)
	}
	if archive == nil || !archive.Quality.IsPartial {
		t.Fatalf("archive = %+v, want partial quality", archive)
	}
	if _, statErr := os.Stat(archivePath); statErr != nil {
		t.Fatalf("archive was not preserved: %v", statErr)
	}
	if len(archive.Queries) != 1 || len(archive.Queries[0].Response) == 0 {
		t.Fatalf("archive queries = %+v, want preserved response", archive.Queries)
	}
	if _, statErr := os.Stat(casePath); !os.IsNotExist(statErr) {
		t.Fatalf("case.json stat error = %v, want not-exist", statErr)
	}
}

func TestFetchO11YRejectsMissingRequiredSeries(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		fmt.Fprint(w, `{"status":"success","isPartial":false,"data":{"resultType":"matrix","result":[]}}`)
	}))
	t.Cleanup(server.Close)

	spec := validO11YExportSpec(server.URL, time.Date(2026, 6, 13, 13, 35, 0, 0, time.UTC))
	spec.Sources = spec.Sources[:1]
	spec.Sources[0].Queries = spec.Sources[0].Queries[:1]
	bundle, archive, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
		Client:      server.Client(),
		BearerToken: testBearerToken,
		ArchivePath: filepath.Join(t.TempDir(), "archive.json"),
	})
	if err == nil || !strings.Contains(err.Error(), "missing required series") {
		t.Fatalf("FetchO11Y() error = %v, want missing series error", err)
	}
	if bundle != nil {
		t.Fatalf("bundle = %+v, want nil", bundle)
	}
	if archive == nil || len(archive.Quality.MissingSeries) != 1 || archive.Quality.MissingSeries[0] != "app_cpu" {
		t.Fatalf("archive quality = %+v, want app_cpu missing", archive)
	}
}

// A per-instance query that returns fewer instances than the cluster had
// replicas must fail loudly: skew and hotspot checks would otherwise read the
// missing instances as idle rather than as absent.
func TestFetchO11YRejectsFewerInstancesThanReplicas(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		fmt.Fprint(w, `{
  "status": "success",
  "isPartial": false,
  "data": {
    "resultType": "matrix",
    "result": [
      {"metric": {"instance": "raw-a"}, "values": [[1781357640, "0.3"], [1781357760, "0.4"]]}
    ]
  }
}`)
	}))
	t.Cleanup(server.Close)

	spec := validO11YExportSpec(server.URL, time.Date(2026, 6, 13, 13, 35, 0, 0, time.UTC))
	spec.Case.Current.Replicas = 3
	spec.Sources = spec.Sources[:1]
	spec.Sources[0].Queries = spec.Sources[0].Queries[:1]
	bundle, archive, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
		Client:      server.Client(),
		BearerToken: testBearerToken,
		ArchivePath: filepath.Join(t.TempDir(), "archive.json"),
	})
	if err == nil || !strings.Contains(err.Error(), "incomplete required series") {
		t.Fatalf("FetchO11Y() error = %v, want incomplete series error", err)
	}
	if bundle != nil {
		t.Fatalf("bundle = %+v, want nil", bundle)
	}
	if archive == nil || len(archive.Quality.IncompleteSeries) != 1 || archive.Quality.IncompleteSeries[0] != "app_cpu" {
		t.Fatalf("archive quality = %+v, want app_cpu incomplete", archive)
	}
}

func TestFetchO11YRejectsMissingInstanceLabel(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		fmt.Fprint(w, `{
  "status": "success",
  "isPartial": false,
  "data": {
    "resultType": "matrix",
    "result": [
      {"metric": {}, "values": [[1781357640, "0.3"], [1781357760, "0.4"]]}
    ]
  }
}`)
	}))
	t.Cleanup(server.Close)

	spec := validO11YExportSpec(server.URL, time.Date(2026, 6, 13, 13, 35, 0, 0, time.UTC))
	spec.Sources = spec.Sources[:1]
	spec.Sources[0].Queries = spec.Sources[0].Queries[:1]
	_, _, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
		Client:      server.Client(),
		BearerToken: testBearerToken,
		ArchivePath: filepath.Join(t.TempDir(), "archive.json"),
	})
	if err == nil || !strings.Contains(err.Error(), "incomplete required series") {
		t.Fatalf("FetchO11Y() error = %v, want incomplete series error", err)
	}
}

// Pods come and go during a scaling event, so a series may start or end inside
// the window. Only a break in collection, or missing coverage at decisionAt,
// makes a case unusable.
func TestFetchO11YAcceptsInstanceChurnInsideWindow(t *testing.T) {
	decisionAt := time.Date(2026, 6, 13, 13, 35, 0, 0, time.UTC)
	minute := func(n int) int64 { return decisionAt.Add(time.Duration(n) * time.Minute).Unix() }

	tests := []struct {
		name            string
		currentReplicas int32
		result          string
		wantErr         bool
	}{
		{
			name:            "new pod starts mid-window",
			currentReplicas: 2,
			result: fmt.Sprintf(`
      {"metric": {"instance": "old"}, "values": [[%d,"0.3"],[%d,"0.3"],[%d,"0.3"]]},
      {"metric": {"instance": "new"}, "values": [[%d,"0.9"],[%d,"0.9"]]}`,
				minute(-2), minute(-1), minute(0), minute(-1), minute(0)),
		},
		{
			name:            "removed pod still leaves enough coverage",
			currentReplicas: 1,
			result: fmt.Sprintf(`
      {"metric": {"instance": "removed"}, "values": [[%d,"0.3"],[%d,"0.3"]]},
      {"metric": {"instance": "kept"}, "values": [[%d,"0.3"],[%d,"0.3"],[%d,"0.3"]]}`,
				minute(-12), minute(-11), minute(-2), minute(-1), minute(0)),
		},
		{
			name:            "removed pod leaves too little coverage",
			currentReplicas: 2,
			result: fmt.Sprintf(`
      {"metric": {"instance": "removed"}, "values": [[%d,"0.3"],[%d,"0.3"]]},
      {"metric": {"instance": "kept"}, "values": [[%d,"0.3"],[%d,"0.3"],[%d,"0.3"]]}`,
				minute(-12), minute(-11), minute(-2), minute(-1), minute(0)),
			wantErr: true,
		},
		{
			name:            "collection breaks inside one series",
			currentReplicas: 1,
			result: fmt.Sprintf(`
      {"metric": {"instance": "only"}, "values": [[%d,"0.3"],[%d,"0.3"]]}`,
				minute(-10), minute(0)),
			wantErr: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				fmt.Fprintf(w, `{"status":"success","isPartial":false,"data":{"resultType":"matrix","result":[%s]}}`, tt.result)
			}))
			t.Cleanup(server.Close)

			spec := validO11YExportSpec(server.URL, decisionAt)
			spec.Case.Current.Replicas = tt.currentReplicas
			spec.Window.Start = decisionAt.Add(-15 * time.Minute)
			spec.Window.End = decisionAt
			spec.Sources = spec.Sources[:1]
			spec.Sources[0].Queries = spec.Sources[0].Queries[:1]
			spec.Sources[0].Queries[0].MaxGapSeconds = 120

			bundle, _, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
				Client:      server.Client(),
				BearerToken: testBearerToken,
				ArchivePath: filepath.Join(t.TempDir(), "archive.json"),
			})
			if (err != nil) != tt.wantErr {
				t.Fatalf("FetchO11Y() error = %v, wantErr %v", err, tt.wantErr)
			}
			if !tt.wantErr && bundle == nil {
				t.Fatal("bundle = nil, want a case bundle")
			}
		})
	}
}

func TestFetchO11YRejectsRequiredSeriesGap(t *testing.T) {
	decisionAt := time.Date(2026, 6, 13, 13, 35, 0, 0, time.UTC)
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		fmt.Fprint(w, `{
  "status": "success",
  "isPartial": false,
  "data": {
    "resultType": "matrix",
    "result": [
      {"metric": {"instance": "raw-a"}, "values": [[1781357100, "0.3"], [1781357700, "0.4"]]}
    ]
  }
}`)
	}))
	t.Cleanup(server.Close)

	spec := validO11YExportSpec(server.URL, decisionAt)
	spec.Window.Start = decisionAt.Add(-10 * time.Minute)
	spec.Window.End = decisionAt
	spec.Sources = spec.Sources[:1]
	spec.Sources[0].Queries = spec.Sources[0].Queries[:1]
	spec.Sources[0].Queries[0].MaxGapSeconds = 90
	bundle, archive, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
		Client:      server.Client(),
		BearerToken: testBearerToken,
		ArchivePath: filepath.Join(t.TempDir(), "archive.json"),
	})
	if err == nil || !strings.Contains(err.Error(), "gap") {
		t.Fatalf("FetchO11Y() error = %v, want gap error", err)
	}
	if bundle != nil {
		t.Fatalf("bundle = %+v, want nil", bundle)
	}
	if archive == nil || len(archive.Quality.GappedSeries) != 1 || archive.Quality.GappedSeries[0] != "app_cpu" {
		t.Fatalf("archive quality = %+v, want app_cpu gap", archive)
	}
}

// Gap and required-series checks describe a fixed historical window, so a
// response they reject is still authoritative and must be cached. Otherwise
// every rejected case re-hits O11Y on each rerun and fails identically.
func TestFetchO11YCachesResponsesRejectedByQualityChecks(t *testing.T) {
	decisionAt := time.Date(2026, 6, 13, 13, 35, 0, 0, time.UTC)
	requests := 0
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		requests++
		fmt.Fprint(w, `{
  "status": "success",
  "isPartial": false,
  "data": {
    "resultType": "matrix",
    "result": [
      {"metric": {"instance": "raw-a"}, "values": [[1781357100, "0.3"], [1781357700, "0.4"]]}
    ]
  }
}`)
	}))
	t.Cleanup(server.Close)

	cacheDir := filepath.Join(t.TempDir(), "cache")
	newSpec := func() *O11YExportSpec {
		spec := validO11YExportSpec(server.URL, decisionAt)
		spec.Window.Start = decisionAt.Add(-10 * time.Minute)
		spec.Window.End = decisionAt
		spec.Sources = spec.Sources[:1]
		spec.Sources[0].Queries = spec.Sources[0].Queries[:1]
		spec.Sources[0].Queries[0].MaxGapSeconds = 90
		return spec
	}

	if _, _, err := FetchO11Y(context.Background(), newSpec(), O11YExportOptions{
		Client:      server.Client(),
		BearerToken: testBearerToken,
		ArchivePath: filepath.Join(t.TempDir(), "first.json"),
		CacheDir:    cacheDir,
	}); err == nil || !strings.Contains(err.Error(), "gap") {
		t.Fatalf("first FetchO11Y() error = %v, want gap error", err)
	}
	if requests != 1 {
		t.Fatalf("requests after first fetch = %d, want 1", requests)
	}

	// The rerun requires no token: a cache hit must satisfy the whole query.
	_, archive, err := FetchO11Y(context.Background(), newSpec(), O11YExportOptions{
		Client:             server.Client(),
		RequireBearerToken: true,
		ArchivePath:        filepath.Join(t.TempDir(), "second.json"),
		CacheDir:           cacheDir,
	})
	if err == nil || !strings.Contains(err.Error(), "gap") {
		t.Fatalf("second FetchO11Y() error = %v, want gap error", err)
	}
	if requests != 1 {
		t.Errorf("requests after second fetch = %d, want 1", requests)
	}
	if archive == nil || len(archive.Queries) != 1 || !archive.Queries[0].CacheHit {
		t.Errorf("second fetch archive = %+v, want a cache hit", archive)
	}
}

// The committed archive is only useful if a normalization change can be
// replayed against it, which requires rebuilding the request-keyed cache from
// the component-grouped archive layout.
func TestSeedO11YCacheRebuildsCacheFromCommittedData(t *testing.T) {
	decisionAt := time.Date(2026, 6, 13, 13, 35, 0, 0, time.UTC)
	requests := 0
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		requests++
		fmt.Fprintf(w, `{"status":"success","isPartial":false,"data":{"resultType":"matrix","result":[
      {"metric": {"instance": "raw-a"}, "values": [[%d,"0.3"],[%d,"0.4"]]}]}}`,
			decisionAt.Add(-time.Minute).Unix(), decisionAt.Unix())
	}))
	t.Cleanup(server.Close)

	spec := validO11YExportSpec(server.URL, decisionAt)
	spec.Sources = spec.Sources[:1]
	spec.Sources[0].Queries = spec.Sources[0].Queries[:1]

	casesDir := t.TempDir()
	if _, _, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
		Client:      server.Client(),
		BearerToken: testBearerToken,
		ArchivePath: filepath.Join(casesDir, spec.Case.Component, PointInTimeCasesDirectory, spec.Case.ID+".json"),
	}); err != nil {
		t.Fatalf("FetchO11Y(): %v", err)
	}

	cacheDir := filepath.Join(t.TempDir(), "cache")
	seeded, err := SeedO11YCache(casesDir, cacheDir)
	if err != nil {
		t.Fatalf("SeedO11YCache(): %v", err)
	}
	if seeded != 1 {
		t.Fatalf("seeded = %d, want 1", seeded)
	}

	// Replaying against the seeded cache must need neither a token nor a request.
	requestsBeforeReplay := requests
	bundle, archive, err := FetchO11Y(context.Background(), spec, O11YExportOptions{
		Client:             server.Client(),
		RequireBearerToken: true,
		ArchivePath:        filepath.Join(t.TempDir(), "replay.json"),
		CacheDir:           cacheDir,
	})
	if err != nil {
		t.Fatalf("replay FetchO11Y(): %v", err)
	}
	if requests != requestsBeforeReplay {
		t.Errorf("requests = %d, want no request during replay", requests-requestsBeforeReplay)
	}
	if bundle == nil || !archive.Queries[0].CacheHit {
		t.Errorf("replay archive = %+v, want a cache hit", archive)
	}
}

func TestFilterMetricLabelsDistinguishesOmittedAndEmptyAllowlist(t *testing.T) {
	labels := map[string]string{"instance": "raw-a", "type": "Query"}
	if got := filterMetricLabels(labels, nil); len(got) != 2 {
		t.Fatalf("omitted keepLabels retained %d labels, want 2", len(got))
	}
	if got := filterMetricLabels(labels, []string{}); len(got) != 0 {
		t.Fatalf("empty keepLabels retained %d labels, want 0", len(got))
	}
}

func TestDecodePrometheusSampleRejectsNonFiniteTimestamp(t *testing.T) {
	_, err := decodePrometheusSample([]json.RawMessage{json.RawMessage(`"NaN"`), json.RawMessage(`"1"`)})
	if err == nil || !strings.Contains(err.Error(), "timestamp must be finite") {
		t.Fatalf("decodePrometheusSample() error = %v, want non-finite timestamp error", err)
	}
}

func validO11YExportSpec(baseURL string, decisionAt time.Time) *O11YExportSpec {
	return &O11YExportSpec{
		Version: O11YExportVersionV1Alpha1,
		Target: O11YTargetSpec{
			ClusterID: "example-cluster",
			Vendor:    "aws",
			Region:    "ap-east-1",
		},
		Case: O11YCaseSpec{
			ID:         "historical-tidb-case",
			Component:  "tidb",
			DecisionAt: decisionAt,
			Current:    Capacity{Replicas: 1},
			Baseline:   Capacity{Replicas: 2},
			Oracle: Oracle{
				Class:    OracleClassUnnecessaryScaleOut,
				Expected: Capacity{Replicas: 1},
			},
		},
		Window: O11YQueryWindow{
			Start:       decisionAt.Add(-30 * time.Minute),
			End:         decisionAt.Add(15 * time.Minute),
			StepSeconds: 60,
		},
		Sources: []O11YSourceSpec{
			{
				Type:    O11YMetricSourceApplication,
				BaseURL: baseURL + "/application/api/v1",
				Queries: []O11YQuerySpec{
					{
						Name:       "app_cpu",
						PromQL:     "app query",
						Type:       O11YQueryTypeRange,
						Required:   true,
						KeepLabels: []string{"instance"},
					},
				},
			},
			{
				Type:    O11YMetricSourceInfrastructure,
				BaseURL: baseURL + "/infrastructure/api/v1",
				Queries: []O11YQuerySpec{
					{
						Name:       "infra_memory",
						PromQL:     "infra query",
						Type:       O11YQueryTypeInstant,
						Required:   true,
						KeepLabels: []string{"metric_name"},
					},
				},
			},
		},
	}
}
