package tests

import (
	"fmt"
	"path/filepath"
	"slices"
	"testing"
	"time"

	at "github.com/VictoriaMetrics/VictoriaMetrics/apptest"
)

type testLegacyDeleteSeriesOpts struct {
	startLegacySUT func() at.PrometheusWriteQuerier
	startNewSUT    func() at.PrometheusWriteQuerier
	stopLegacySUT  func()
	stopNewSUT     func()
}

func TestLegacySingleDeleteSeries(t *testing.T) {
	tc := at.NewTestCase(t)
	defer tc.Stop()

	storageDataPath := filepath.Join(tc.Dir(), "vmsingle")

	opts := testLegacyDeleteSeriesOpts{
		startLegacySUT: func() at.PrometheusWriteQuerier {
			return tc.MustStartVmsingle_v1_132_0("vmsingle-legacy", []string{
				"-storageDataPath=" + storageDataPath,
				"-retentionPeriod=100y",
				"-search.maxStalenessInterval=1m",
			})
		},
		startNewSUT: func() at.PrometheusWriteQuerier {
			return tc.MustStartVmsingle("vmsingle-new", []string{
				"-storageDataPath=" + storageDataPath,
				"-retentionPeriod=100y",
				"-search.maxStalenessInterval=1m",
			})
		},
		stopLegacySUT: func() {
			tc.StopApp("vmsingle-legacy")
		},
		stopNewSUT: func() {
			tc.StopApp("vmsingle-new")
		},
	}

	testLegacyDeleteSeries(tc, opts)
}

func TestLegacyClusterDeleteSeries(t *testing.T) {
	tc := at.NewTestCase(t)
	defer tc.Stop()

	storage1DataPath := filepath.Join(tc.Dir(), "vmstorage1")
	storage2DataPath := filepath.Join(tc.Dir(), "vmstorage2")

	opts := testLegacyDeleteSeriesOpts{
		startLegacySUT: func() at.PrometheusWriteQuerier {
			return tc.MustStartCluster_v1_132_0(&at.ClusterOptions{
				Vmstorage1Instance: "vmstorage1-legacy",
				Vmstorage1Flags: []string{
					"-storageDataPath=" + storage1DataPath,
					"-retentionPeriod=100y",
				},
				Vmstorage2Instance: "vmstorage2-legacy",
				Vmstorage2Flags: []string{
					"-storageDataPath=" + storage2DataPath,
					"-retentionPeriod=100y",
				},
				VminsertInstance: "vminsert",
				VminsertFlags:    []string{},
				VmselectInstance: "vmselect",
				VmselectFlags: []string{
					"-search.maxStalenessInterval=1m",
				},
			})
		},
		startNewSUT: func() at.PrometheusWriteQuerier {
			return tc.MustStartCluster(&at.ClusterOptions{
				Vmstorage1Instance: "vmstorage1-new",
				Vmstorage1Flags: []string{
					"-storageDataPath=" + storage1DataPath,
					"-retentionPeriod=100y",
				},
				Vmstorage2Instance: "vmstorage2-new",
				Vmstorage2Flags: []string{
					"-storageDataPath=" + storage2DataPath,
					"-retentionPeriod=100y",
				},
				VminsertInstance: "vminsert",
				VminsertFlags:    []string{},
				VmselectInstance: "vmselect",
				VmselectFlags: []string{
					"-search.maxStalenessInterval=1m",
				},
			})
		},
		stopLegacySUT: func() {
			tc.StopApp("vminsert")
			tc.StopApp("vmselect")
			tc.StopApp("vmstorage1-legacy")
			tc.StopApp("vmstorage2-legacy")
		},
		stopNewSUT: func() {
			tc.StopApp("vminsert")
			tc.StopApp("vmselect")
			tc.StopApp("vmstorage1-new")
			tc.StopApp("vmstorage2-new")
		},
	}

	testLegacyDeleteSeries(tc, opts)
}

func testLegacyDeleteSeries(tc *at.TestCase, opts testLegacyDeleteSeriesOpts) {
	t := tc.T()

	type want struct {
		series       []map[string]string
		queryResults []*at.QueryResult
	}

	genData := func(prefix string, start, end, step int64, value float64) (recs []string, w *want) {
		count := (end - start) / step
		recs = make([]string, count)
		w = &want{
			series:       make([]map[string]string, count),
			queryResults: make([]*at.QueryResult, count),
		}
		for i := range count {
			name := fmt.Sprintf("%s_%03d", prefix, i)
			timestamp := start + int64(i)*step

			recs[i] = fmt.Sprintf("%s %f %d", name, value, timestamp)
			w.series[i] = map[string]string{"__name__": name}
			w.queryResults[i] = &at.QueryResult{
				Metric:  map[string]string{"__name__": name},
				Samples: []*at.Sample{{Timestamp: timestamp, Value: value}},
			}
		}
		return recs, w
	}

	assertSearchResults := func(app at.PrometheusQuerier, query string, start, end int64, step string, want *want) {
		t.Helper()

		tc.Assert(&at.AssertOptions{
			Msg: "unexpected /api/v1/series response",
			Got: func() any {
				return app.PrometheusAPIV1Series(t, query, at.QueryOpts{
					Start: fmt.Sprintf("%d", start),
					End:   fmt.Sprintf("%d", end),
				}).Sort()
			},
			Want: &at.PrometheusAPIV1SeriesResponse{
				Status: "success",
				Data:   want.series,
			},
			FailNow: true,
		})

		tc.Assert(&at.AssertOptions{
			Msg: "unexpected /api/v1/query_range response",
			Got: func() any {
				return app.PrometheusAPIV1QueryRange(t, query, at.QueryOpts{
					Start: fmt.Sprintf("%d", start),
					End:   fmt.Sprintf("%d", end),
					Step:  step,
				})
			},
			Want: &at.PrometheusAPIV1QueryResponse{
				Status: "success",
				Data: &at.QueryData{
					ResultType: "matrix",
					Result:     want.queryResults,
				},
			},
			FailNow: true,
		})
	}

	// - start legacy vmsingle
	// - insert data1
	// - confirm that metric names and samples are searchable
	// - stop legacy vmsingle
	const step = 24 * 3600 * 1000 // 24h
	start1 := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli()
	end1 := time.Date(2000, 1, 10, 0, 0, 0, 0, time.UTC).UnixMilli()
	data1, want1 := genData("metric", start1, end1, step, 1)
	legacySUT := opts.startLegacySUT()
	legacySUT.PrometheusAPIV1ImportPrometheus(t, data1, at.QueryOpts{})
	legacySUT.ForceFlush(t)
	assertSearchResults(legacySUT, `{__name__=~".*"}`, start1, end1, "1d", want1)
	opts.stopLegacySUT()

	// - start new vmsingle
	// - confirm that data1 metric names and samples are searchable
	// - delete data1
	// - confirm that data1 metric names and samples are not searchable anymore
	// - insert data2 (same metric names, different dates)
	// - confirm that metric names become searchable again
	// - confirm that data1 samples are not searchable and data2 samples are searchable

	newSUT := opts.startNewSUT()
	assertSearchResults(newSUT, `{__name__=~".*"}`, start1, end1, "1d", want1)

	newSUT.PrometheusAPIV1AdminTSDBDeleteSeries(t, `{__name__=~".*"}`, at.QueryOpts{})
	wantNoResults := &want{
		series:       []map[string]string{},
		queryResults: []*at.QueryResult{},
	}
	assertSearchResults(newSUT, `{__name__=~".*"}`, start1, end1, "1d", wantNoResults)

	start2 := time.Date(2000, 1, 11, 0, 0, 0, 0, time.UTC).UnixMilli()
	end2 := time.Date(2000, 1, 20, 0, 0, 0, 0, time.UTC).UnixMilli()
	data2, want2 := genData("metric", start2, end2, step, 2)
	newSUT.PrometheusAPIV1ImportPrometheus(t, data2, at.QueryOpts{})
	newSUT.ForceFlush(t)
	assertSearchResults(newSUT, `{__name__=~".*"}`, start1, end2, "1d", want2)

	// - restart new vmsingle
	// - confirm that metric names still searchable, data1 samples are not
	//   searchable, and data2 samples are searchable

	opts.stopNewSUT()
	newSUT = opts.startNewSUT()
	assertSearchResults(newSUT, `{__name__=~".*"}`, start1, end2, "1d", want2)
	opts.stopNewSUT()
}

type testLegacyBackupRestoreOpts struct {
	startLegacySUT     func() at.PrometheusWriteQuerier
	startNewSUT        func() at.PrometheusWriteQuerier
	stopLegacySUT      func()
	stopNewSUT         func()
	storageDataPaths   []string
	snapshotCreateURLs func(at.PrometheusWriteQuerier) []string
}

func TestLegacySingleBackupRestore(t *testing.T) {
	tc := at.NewTestCase(t)
	defer tc.Stop()

	storageDataPath := filepath.Join(tc.Dir(), "vmsingle")

	opts := testLegacyBackupRestoreOpts{
		startLegacySUT: func() at.PrometheusWriteQuerier {
			return tc.MustStartVmsingle_v1_132_0("vmsingle-legacy", []string{
				"-storageDataPath=" + storageDataPath,
				"-retentionPeriod=100y",
				"-search.disableCache=true",
				"-search.maxStalenessInterval=1m",
			})
		},
		startNewSUT: func() at.PrometheusWriteQuerier {
			return tc.MustStartVmsingle("vmsingle-new", []string{
				"-storageDataPath=" + storageDataPath,
				"-retentionPeriod=100y",
				"-search.disableCache=true",
				"-search.maxStalenessInterval=1m",
			})
		},
		stopLegacySUT: func() {
			tc.StopApp("vmsingle-legacy")
		},
		stopNewSUT: func() {
			tc.StopApp("vmsingle-new")
		},
		storageDataPaths: []string{
			storageDataPath,
		},
		snapshotCreateURLs: func(sut at.PrometheusWriteQuerier) []string {
			return []string{
				sut.(*at.Vmsingle).SnapshotCreateURL(),
			}
		},
	}

	testLegacyBackupRestore(tc, opts)
}

func TestLegacyClusterBackupRestore(t *testing.T) {
	tc := at.NewTestCase(t)
	defer tc.Stop()

	storage1DataPath := filepath.Join(tc.Dir(), "vmstorage1")
	storage2DataPath := filepath.Join(tc.Dir(), "vmstorage2")

	opts := testLegacyBackupRestoreOpts{
		startLegacySUT: func() at.PrometheusWriteQuerier {
			return tc.MustStartCluster_v1_132_0(&at.ClusterOptions{
				Vmstorage1Instance: "vmstorage1-legacy",
				Vmstorage1Flags: []string{
					"-storageDataPath=" + storage1DataPath,
					"-retentionPeriod=100y",
				},
				Vmstorage2Instance: "vmstorage2-legacy",
				Vmstorage2Flags: []string{
					"-storageDataPath=" + storage2DataPath,
					"-retentionPeriod=100y",
				},
				VminsertInstance: "vminsert",
				VminsertFlags:    []string{},
				VmselectInstance: "vmselect",
				VmselectFlags: []string{
					"-search.disableCache=true",
					"-search.maxStalenessInterval=1m",
				},
			})
		},
		startNewSUT: func() at.PrometheusWriteQuerier {
			return tc.MustStartCluster(&at.ClusterOptions{
				Vmstorage1Instance: "vmstorage1-new",
				Vmstorage1Flags: []string{
					"-storageDataPath=" + storage1DataPath,
					"-retentionPeriod=100y",
				},
				Vmstorage2Instance: "vmstorage2-new",
				Vmstorage2Flags: []string{
					"-storageDataPath=" + storage2DataPath,
					"-retentionPeriod=100y",
				},
				VminsertInstance: "vminsert",
				VmselectInstance: "vmselect",
				VmselectFlags: []string{
					"-search.disableCache=true",
					"-search.maxStalenessInterval=1m",
				},
			})
		},
		stopLegacySUT: func() {
			tc.StopApp("vminsert")
			tc.StopApp("vmselect")
			tc.StopApp("vmstorage1-legacy")
			tc.StopApp("vmstorage2-legacy")
		},
		stopNewSUT: func() {
			tc.StopApp("vminsert")
			tc.StopApp("vmselect")
			tc.StopApp("vmstorage1-new")
			tc.StopApp("vmstorage2-new")
		},
		storageDataPaths: []string{
			storage1DataPath,
			storage2DataPath,
		},
		snapshotCreateURLs: func(sut at.PrometheusWriteQuerier) []string {
			c := sut.(*at.Vmcluster)
			return []string{
				c.Vmstorages[0].SnapshotCreateURL(),
				c.Vmstorages[1].SnapshotCreateURL(),
			}
		},
	}

	testLegacyBackupRestore(tc, opts)
}

func testLegacyBackupRestore(tc *at.TestCase, opts testLegacyBackupRestoreOpts) {
	t := tc.T()

	const msecPerMinute = 60 * 1000
	// Use the same number of metrics and time range for all the data ingestions
	// below.
	const numMetrics = 1000
	start := time.Date(2025, 3, 1, 10, 0, 0, 0, time.UTC).Add(-numMetrics * time.Minute).UnixMilli()
	end := time.Date(2025, 3, 1, 10, 0, 0, 0, time.UTC).UnixMilli()
	genData := func(prefix string) (recs []string, wantSeries []map[string]string, wantQueryResults []*at.QueryResult) {
		recs = make([]string, numMetrics)
		wantSeries = make([]map[string]string, numMetrics)
		wantQueryResults = make([]*at.QueryResult, numMetrics)
		for i := range numMetrics {
			name := fmt.Sprintf("%s_%03d", prefix, i)
			value := float64(i)
			timestamp := start + int64(i)*msecPerMinute

			recs[i] = fmt.Sprintf("%s %f %d", name, value, timestamp)
			wantSeries[i] = map[string]string{"__name__": name}
			wantQueryResults[i] = &at.QueryResult{
				Metric:  map[string]string{"__name__": name},
				Samples: []*at.Sample{{Timestamp: timestamp, Value: value}},
			}
		}
		return recs, wantSeries, wantQueryResults
	}

	backupBaseDir, err := filepath.Abs(filepath.Join(tc.Dir(), "backups"))
	if err != nil {
		t.Fatalf("could not get absolute path for the backup base dir")
	}

	// assertSeries issues various queries to the app and compares the query
	// results with the expected ones.
	assertQueries := func(app at.PrometheusQuerier, query string, wantSeries []map[string]string, wantQueryResults []*at.QueryResult) {
		t.Helper()
		tc.Assert(&at.AssertOptions{
			Msg: "unexpected /api/v1/series response",
			Got: func() any {
				return app.PrometheusAPIV1Series(t, query, at.QueryOpts{
					Start: fmt.Sprintf("%d", start),
					End:   fmt.Sprintf("%d", end),
				}).Sort()
			},
			Want: &at.PrometheusAPIV1SeriesResponse{
				Status: "success",
				Data:   wantSeries,
			},
			FailNow: true,
		})

		tc.Assert(&at.AssertOptions{
			Msg: "unexpected /api/v1/query_range response",
			Got: func() any {
				return app.PrometheusAPIV1QueryRange(t, query, at.QueryOpts{
					Start: fmt.Sprintf("%d", start),
					End:   fmt.Sprintf("%d", end),
					Step:  "60s",
				})
			},
			Want: &at.PrometheusAPIV1QueryResponse{
				Status: "success",
				Data: &at.QueryData{
					ResultType: "matrix",
					Result:     wantQueryResults,
				},
			},
			Retries: 300,
			FailNow: true,
		})
	}

	createBackup := func(sut at.PrometheusWriteQuerier, name string) {
		t.Helper()
		for i, storageDataPath := range opts.storageDataPaths {
			replica := fmt.Sprintf("replica-%d", i)
			instance := fmt.Sprintf("vmbackup-%s-%s", name, replica)
			snapshotCreateURL := opts.snapshotCreateURLs(sut)[i]
			backupPath := "fs://" + filepath.Join(backupBaseDir, name, replica)
			tc.MustStartVmbackup(instance, storageDataPath, snapshotCreateURL, backupPath)
		}
	}

	restoreFromBackup := func(name string) {
		t.Helper()
		for i, storageDataPath := range opts.storageDataPaths {
			replica := fmt.Sprintf("replica-%d", i)
			instance := fmt.Sprintf("vmrestore-%s-%s", name, replica)
			backupPath := "fs://" + filepath.Join(backupBaseDir, name, replica)
			tc.MustStartVmrestore(instance, backupPath, storageDataPath)
		}
	}

	legacy1Data, wantLegacy1Series, wantLegacy1QueryResults := genData("legacy1")
	legacy2Data, wantLegacy2Series, wantLegacy2QueryResults := genData("legacy2")
	new1Data, wantNew1Series, wantNew1QueryResults := genData("new1")
	new2Data, wantNew2Series, wantNew2QueryResults := genData("new2")
	wantLegacy12Series := slices.Concat(wantLegacy1Series, wantLegacy2Series)
	wantLegacy12QueryResults := slices.Concat(wantLegacy1QueryResults, wantLegacy2QueryResults)
	wantLegacy1New1Series := slices.Concat(wantLegacy1Series, wantNew1Series)
	wantLegacy1New1QueryResults := slices.Concat(wantLegacy1QueryResults, wantNew1QueryResults)
	wantLegacy1New12Series := slices.Concat(wantLegacy1New1Series, wantNew2Series)
	wantLegacy1New12QueryResults := slices.Concat(wantLegacy1New1QueryResults, wantNew2QueryResults)
	var legacySUT, newSUT at.PrometheusWriteQuerier

	// Verify backup/restore with legacy SUT.

	// Start legacy SUT with empty storage data dir.
	legacySUT = opts.startLegacySUT()

	// Ingest legacy1 records, ensure the queries return legacy1, and create
	// legacy1 backup.
	legacySUT.PrometheusAPIV1ImportPrometheus(t, legacy1Data, at.QueryOpts{})
	legacySUT.ForceFlush(t)
	assertQueries(legacySUT, `{__name__=~".*"}`, wantLegacy1Series, wantLegacy1QueryResults)
	createBackup(legacySUT, "legacy1")

	// Ingest legacy2 records, ensure the queries return legacy1+legacy2, and
	// create legacy1+legacy2 backup.
	legacySUT.PrometheusAPIV1ImportPrometheus(t, legacy2Data, at.QueryOpts{})
	legacySUT.ForceFlush(t)
	assertQueries(legacySUT, `{__name__=~"legacy.*"}`, wantLegacy12Series, wantLegacy12QueryResults)
	createBackup(legacySUT, "legacy12")

	// Stop legacy SUT and restore legacy1 data.
	// Start legacy SUT and ensure the queries return legacy1.
	opts.stopLegacySUT()
	restoreFromBackup("legacy1")
	legacySUT = opts.startLegacySUT()
	assertQueries(legacySUT, `{__name__=~".*"}`, wantLegacy1Series, wantLegacy1QueryResults)

	opts.stopLegacySUT()

	// Verify backup/restore with new SUT.

	// Start new SUT (with partition indexDBs) with storage containing legacy1
	// data and Ensure that queries return legacy1 data.
	newSUT = opts.startNewSUT()
	assertQueries(newSUT, `{__name__=~".*"}`, wantLegacy1Series, wantLegacy1QueryResults)

	// Ingest new1 records, ensure that queries now return legacy1+new1, and
	// create the legacy1+new1 backup.
	newSUT.PrometheusAPIV1ImportPrometheus(t, new1Data, at.QueryOpts{})
	newSUT.ForceFlush(t)
	assertQueries(newSUT, `{__name__=~"(legacy|new).*"}`, wantLegacy1New1Series, wantLegacy1New1QueryResults)
	createBackup(newSUT, "legacy1-new1")

	// Ingest new2 records, ensure that queries now return legacy1+new1+new2,
	// and create the legacy1+new1+new2 backup.
	newSUT.PrometheusAPIV1ImportPrometheus(t, new2Data, at.QueryOpts{})
	newSUT.ForceFlush(t)
	assertQueries(newSUT, `{__name__=~"(legacy|new1|new2).*"}`, wantLegacy1New12Series, wantLegacy1New12QueryResults)
	createBackup(newSUT, "legacy1-new12")

	// Stop new SUT and restore legacy1+new1 data.
	// Start new SUT and ensure queries return legacy1+new1 data.
	opts.stopNewSUT()
	restoreFromBackup("legacy1-new1")
	newSUT = opts.startNewSUT()
	assertQueries(newSUT, `{__name__=~".*"}`, wantLegacy1New1Series, wantLegacy1New1QueryResults)

	opts.stopNewSUT()

	// Verify backup/restore with legacy SUT again.

	// Start legacy SUT with storage containing legacy1+new1 data.
	//
	// Ensure that the /series and /query_range queries return legacy1 data only.
	// new1 data is not returned because legacy vmsingle does not know about
	// partition indexDBs.
	legacySUT = opts.startLegacySUT()
	assertQueries(legacySUT, `{__name__=~".*"}`, wantLegacy1Series, wantLegacy1QueryResults)

	// Stop legacy SUT and restore legacy1+legacy2 data.
	// Start legacy SUT and ensure that queries now return legacy1+legacy2 data.
	opts.stopLegacySUT()
	restoreFromBackup("legacy12")
	legacySUT = opts.startLegacySUT()
	assertQueries(legacySUT, `{__name__=~".*"}`, wantLegacy12Series, wantLegacy12QueryResults)

	opts.stopLegacySUT()

	// Verify backup/restore with new vmsingle again.

	// Start new vmsingle with storage containing legacy1+legacy2 data and
	// ensure that queries return legacy1+legacy2 data.
	newSUT = opts.startNewSUT()
	assertQueries(newSUT, `{__name__=~".*"}`, wantLegacy12Series, wantLegacy12QueryResults)

	// Stop new SUT and restore legacy1+new1+new2 data.
	// Start new SUT and ensure that queries return legacy1+new1+new2 data.
	opts.stopNewSUT()
	restoreFromBackup("legacy1-new12")
	newSUT = opts.startNewSUT()
	assertQueries(newSUT, `{__name__=~"(legacy|new).*"}`, wantLegacy1New12Series, wantLegacy1New12QueryResults)

	opts.stopNewSUT()
}

type testLegacyDowngradeOpts struct {
	startLegacySUT func() at.PrometheusWriteQuerier
	startNewSUT    func() at.PrometheusWriteQuerier
	stopLegacySUT  func()
	stopNewSUT     func()
}

func TestLegacySingleDowngrade(t *testing.T) {
	tc := at.NewTestCase(t)
	defer tc.Stop()

	storageDataPath := filepath.Join(tc.Dir(), "vmsingle")

	opts := testLegacyDowngradeOpts{
		startLegacySUT: func() at.PrometheusWriteQuerier {
			return tc.MustStartVmsingle_v1_132_0("vmsingle-legacy", []string{
				"-storageDataPath=" + storageDataPath,
				"-retentionPeriod=100y",
				"-search.disableCache=true",
				"-search.maxStalenessInterval=1m",
			})
		},
		startNewSUT: func() at.PrometheusWriteQuerier {
			return tc.MustStartVmsingle("vmsingle-new", []string{
				"-storageDataPath=" + storageDataPath,
				"-retentionPeriod=100y",
				"-search.disableCache=true",
				"-search.maxStalenessInterval=1m",
			})
		},
		stopLegacySUT: func() {
			tc.StopApp("vmsingle-legacy")
		},
		stopNewSUT: func() {
			tc.StopApp("vmsingle-new")
		},
	}

	testLegacyDowngrade(tc, opts)
}

func TestLegacyClusterDowngrade(t *testing.T) {
	tc := at.NewTestCase(t)
	defer tc.Stop()

	storage1DataPath := filepath.Join(tc.Dir(), "vmstorage1")
	storage2DataPath := filepath.Join(tc.Dir(), "vmstorage2")

	opts := testLegacyDowngradeOpts{
		startLegacySUT: func() at.PrometheusWriteQuerier {
			return tc.MustStartCluster_v1_132_0(&at.ClusterOptions{
				Vmstorage1Instance: "vmstorage1-legacy",
				Vmstorage1Flags: []string{
					"-storageDataPath=" + storage1DataPath,
					"-retentionPeriod=100y",
				},
				Vmstorage2Instance: "vmstorage2-legacy",
				Vmstorage2Flags: []string{
					"-storageDataPath=" + storage2DataPath,
					"-retentionPeriod=100y",
				},
				VminsertInstance: "vminsert",
				VminsertFlags:    []string{},
				VmselectInstance: "vmselect",
				VmselectFlags: []string{
					"-search.disableCache=true",
					"-search.maxStalenessInterval=1m",
				},
			})
		},
		startNewSUT: func() at.PrometheusWriteQuerier {
			return tc.MustStartCluster(&at.ClusterOptions{
				Vmstorage1Instance: "vmstorage1-new",
				Vmstorage1Flags: []string{
					"-storageDataPath=" + storage1DataPath,
					"-retentionPeriod=100y",
				},
				Vmstorage2Instance: "vmstorage2-new",
				Vmstorage2Flags: []string{
					"-storageDataPath=" + storage2DataPath,
					"-retentionPeriod=100y",
				},
				VminsertInstance: "vminsert",
				VminsertFlags:    []string{},
				VmselectInstance: "vmselect",
				VmselectFlags: []string{
					"-search.disableCache=true",
					"-search.maxStalenessInterval=1m",
				},
			})
		},
		stopLegacySUT: func() {
			tc.StopApp("vminsert")
			tc.StopApp("vmselect")
			tc.StopApp("vmstorage1-legacy")
			tc.StopApp("vmstorage2-legacy")
		},
		stopNewSUT: func() {
			tc.StopApp("vminsert")
			tc.StopApp("vmselect")
			tc.StopApp("vmstorage1-new")
			tc.StopApp("vmstorage2-new")
		},
	}

	testLegacyDowngrade(tc, opts)
}

func testLegacyDowngrade(tc *at.TestCase, opts testLegacyDowngradeOpts) {
	t := tc.T()

	type want struct {
		series            []map[string]string
		labels            []string
		labelValues       []string
		queryResults      []*at.QueryResult
		queryRangeResults []*at.QueryResult
	}

	uniq := func(s []string) []string {
		slices.Sort(s)
		return slices.Compact(s)
	}

	mergeWant := func(want1, want2 want) want {
		var result want
		result.series = slices.Concat(want1.series, want2.series)
		result.labels = uniq(slices.Concat(want1.labels, want2.labels))
		result.labelValues = slices.Concat(want1.labelValues, want2.labelValues)
		result.queryResults = slices.Concat(want1.queryResults, want2.queryResults)
		result.queryRangeResults = slices.Concat(want1.queryRangeResults, want2.queryRangeResults)

		return result
	}

	// Use the same number of metrics and time range for all the data batches below.
	const numMetrics = 1000
	const labelName = "prefix"
	start := time.Date(2025, 3, 1, 10, 0, 0, 0, time.UTC).UnixMilli()
	end := start
	genData := func(prefix string) (recs []string, want want) {
		labelValue := prefix
		recs = make([]string, numMetrics)
		want.series = make([]map[string]string, numMetrics)
		want.labels = []string{"__name__", labelName}
		want.labelValues = []string{labelValue}
		want.queryResults = make([]*at.QueryResult, numMetrics)
		want.queryRangeResults = make([]*at.QueryResult, numMetrics)
		for i := range numMetrics {
			name := fmt.Sprintf("%s_%03d", prefix, i)
			value := float64(i)
			timestamp := start

			recs[i] = fmt.Sprintf("%s{%s=\"%s\"} %f %d", name, labelName, labelValue, value, timestamp)
			want.series[i] = map[string]string{"__name__": name, labelName: labelValue}
			want.queryResults[i] = &at.QueryResult{
				Metric: map[string]string{"__name__": name, labelName: labelValue},
				Sample: &at.Sample{Timestamp: timestamp, Value: value},
			}
			want.queryRangeResults[i] = &at.QueryResult{
				Metric:  map[string]string{"__name__": name, labelName: labelValue},
				Samples: []*at.Sample{{Timestamp: timestamp, Value: value}},
			}
		}
		return recs, want
	}

	// assertSeries issues various queries to the app and compares the query
	// results with the expected ones.
	assertQueries := func(app at.PrometheusQuerier, query string, want want, wantSeriesCount uint64) {
		t.Helper()
		tc.Assert(&at.AssertOptions{
			Msg: "unexpected /api/v1/series response",
			Got: func() any {
				return app.PrometheusAPIV1Series(t, query, at.QueryOpts{
					Start: fmt.Sprintf("%d", start),
					End:   fmt.Sprintf("%d", end),
				}).Sort()
			},
			Want: &at.PrometheusAPIV1SeriesResponse{
				Status: "success",
				Data:   want.series,
			},
			FailNow: true,
		})

		tc.Assert(&at.AssertOptions{
			Msg: "unexpected /api/v1/series/count response",
			Got: func() any {
				return app.PrometheusAPIV1SeriesCount(t, at.QueryOpts{
					Start: fmt.Sprintf("%d", start),
					End:   fmt.Sprintf("%d", end),
				})
			},
			Want: &at.PrometheusAPIV1SeriesCountResponse{
				Status: "success",
				Data:   []uint64{wantSeriesCount},
			},
			FailNow: true,
		})

		tc.Assert(&at.AssertOptions{
			Msg: "unexpected /api/v1/labels response",
			Got: func() any {
				return app.PrometheusAPIV1Labels(t, query, at.QueryOpts{
					Start: fmt.Sprintf("%d", start),
					End:   fmt.Sprintf("%d", end),
				})
			},
			Want: &at.PrometheusAPIV1LabelsResponse{
				Status: "success",
				Data:   want.labels,
			},
			FailNow: true,
		})

		tc.Assert(&at.AssertOptions{
			Msg: "unexpected /api/v1/label/../values response",
			Got: func() any {
				return app.PrometheusAPIV1LabelValues(t, labelName, query, at.QueryOpts{
					Start: fmt.Sprintf("%d", start),
					End:   fmt.Sprintf("%d", end),
				})
			},
			Want: &at.PrometheusAPIV1LabelValuesResponse{
				Status: "success",
				Data:   want.labelValues,
			},
			FailNow: true,
		})

		tc.Assert(&at.AssertOptions{
			Msg: "unexpected /api/v1/query response",
			Got: func() any {
				return app.PrometheusAPIV1Query(t, query, at.QueryOpts{
					Time: fmt.Sprintf("%d", start),
					Step: "10m",
				})
			},
			Want: &at.PrometheusAPIV1QueryResponse{
				Status: "success",
				Data: &at.QueryData{
					ResultType: "vector",
					Result:     want.queryResults,
				},
			},
			Retries: 300,
			FailNow: true,
		})

		tc.Assert(&at.AssertOptions{
			Msg: "unexpected /api/v1/query_range response",
			Got: func() any {
				return app.PrometheusAPIV1QueryRange(t, query, at.QueryOpts{
					Start: fmt.Sprintf("%d", start),
					End:   fmt.Sprintf("%d", end),
					Step:  "60s",
				})
			},
			Want: &at.PrometheusAPIV1QueryResponse{
				Status: "success",
				Data: &at.QueryData{
					ResultType: "matrix",
					Result:     want.queryRangeResults,
				},
			},
			Retries: 300,
			FailNow: true,
		})
	}

	wantEmpty := want{
		series:            []map[string]string{},
		labels:            []string{"__name__"},
		labelValues:       []string{},
		queryResults:      []*at.QueryResult{},
		queryRangeResults: []*at.QueryResult{},
	}

	legacy1Data, wantLegacy1 := genData("legacy1")
	legacy2Data, wantLegacy2 := genData("legacy2")
	new1Data, wantNew1 := genData("new1")
	wantLegacy1New1 := mergeWant(wantLegacy1, wantNew1)
	wantLegacy2New1 := mergeWant(wantLegacy2, wantNew1)
	var legacySUT, newSUT at.PrometheusWriteQuerier

	// Start legacy SUT with empty storage data dir.
	// Ingest legacy1 records, ensure the queries return legacy1
	legacySUT = opts.startLegacySUT()
	legacySUT.PrometheusAPIV1ImportPrometheus(t, legacy1Data, at.QueryOpts{})
	legacySUT.ForceFlush(t)
	assertQueries(legacySUT, `{__name__=~".*"}`, wantLegacy1, numMetrics)
	opts.stopLegacySUT()

	// Start new SUT (with partition indexDBs) with storage containing legacy1
	// data and ensure that queries return new1 and legacy1 data.
	newSUT = opts.startNewSUT()
	newSUT.PrometheusAPIV1ImportPrometheus(t, new1Data, at.QueryOpts{})
	newSUT.ForceFlush(t)
	assertQueries(newSUT, `{__name__=~".*"}`, wantLegacy1New1, 2*numMetrics)
	opts.stopNewSUT()

	// Downgrade to legacy SUT, ensure the queries return only legacy1.
	// Delete all series, ensure that queries return no series.
	// Ingest legacy2 records, ensure the queries return only legacy2.
	legacySUT = opts.startLegacySUT()
	assertQueries(legacySUT, `{__name__=~".*"}`, wantLegacy1, numMetrics)
	legacySUT.PrometheusAPIV1AdminTSDBDeleteSeries(t, `{__name__=~".*"}`, at.QueryOpts{})
	assertQueries(legacySUT, `{__name__=~".*"}`, wantEmpty, numMetrics)
	legacySUT.PrometheusAPIV1ImportPrometheus(t, legacy2Data, at.QueryOpts{})
	legacySUT.ForceFlush(t)
	// series count includes deleted metrics
	assertQueries(legacySUT, `{__name__=~".*"}`, wantLegacy2, 2*numMetrics)
	opts.stopLegacySUT()

	// Upgrade to new SUT, ensure the queries return recently ingested legacy2 and new1
	// since legacy SUT cannot delete them.
	// Delete all series, ensure that queries return no series.
	newSUT = opts.startNewSUT()
	// series count includes deleted metrics
	assertQueries(newSUT, `{__name__=~".*"}`, wantLegacy2New1, 3*numMetrics)
	newSUT.PrometheusAPIV1AdminTSDBDeleteSeries(t, `{__name__=~".*"}`, at.QueryOpts{})
	// series count includes deleted metrics
	assertQueries(newSUT, `{__name__=~".*"}`, wantEmpty, 3*numMetrics)
	opts.stopNewSUT()
}
