package migration

import (
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"

	"github.com/tidbcloud/observability/vm-migration/internal/jsonio"
	"github.com/tidbcloud/observability/vm-migration/internal/schema"
)

func writeTuneFile(t *testing.T, dir, content string) {
	t.Helper()
	assert.NoError(t, os.WriteFile(filepath.Join(dir, TuneFile), []byte(content), 0o644))
}

func TestLoadTuneDoc(t *testing.T) {
	dir := t.TempDir()
	tunePath := filepath.Join(dir, TuneFile)

	_, err := LoadTuneDoc("")
	assert.NoError(t, err, "empty path means no tune configured")

	_, err = LoadTuneDoc(tunePath)
	assert.NoError(t, err, "missing file means inherit the plan")

	writeTuneFile(t, dir, `{"vmctl": {"rateLimitBytes": 100, "concurrency": 4}}`)
	doc, err := LoadTuneDoc(tunePath)
	assert.NoError(t, err)
	assert.Equal(t, 100, *doc.Vmctl.RateLimitBytes)
	assert.Equal(t, 4, *doc.Vmctl.Concurrency)

	writeTuneFile(t, dir, `not json`)
	_, err = LoadTuneDoc(tunePath)
	assert.ErrorContains(t, err, TuneFile)

	writeTuneFile(t, dir, `{"unknown": 1}`)
	_, err = LoadTuneDoc(tunePath)
	assert.ErrorContains(t, err, "unknown field", "strict decode must reject typos")

	writeTuneFile(t, dir, `{"vmctl": {"concurrency": 0}}`)
	_, err = LoadTuneDoc(tunePath)
	assert.ErrorContains(t, err, "concurrency must be >= 1")

	writeTuneFile(t, dir, `{"vmselect": {"migration": {"replicas": 0}}}`)
	_, err = LoadTuneDoc(tunePath)
	assert.ErrorContains(t, err, "replicas must be between 1 and 10")

	writeTuneFile(t, dir, `{"vmselect": {"candidate": {"image": ""}}}`)
	_, err = LoadTuneDoc(tunePath)
	assert.ErrorContains(t, err, "image must not be empty")
}

func TestUpdateTuneDoc(t *testing.T) {
	dir := t.TempDir()
	tunePath := filepath.Join(dir, TuneFile)

	concurrency := 7
	assert.NoError(t, UpdateTuneDoc(tunePath, func(d *schema.TuneDoc) {
		d.Vmctl.Concurrency = &concurrency
	}))
	doc, err := LoadTuneDoc(tunePath)
	assert.NoError(t, err)
	assert.Equal(t, 7, *doc.Vmctl.Concurrency)

	replicas := 2
	assert.NoError(t, UpdateTuneDoc(tunePath, func(d *schema.TuneDoc) {
		d.Vmselect.Migration = &schema.TuneTarget{Replicas: &replicas}
	}))
	doc, err = LoadTuneDoc(tunePath)
	assert.NoError(t, err)
	assert.Equal(t, 7, *doc.Vmctl.Concurrency, "section update leaves the other section alone")
	assert.Equal(t, 2, *doc.Vmselect.Migration.Replicas)

	assert.NoError(t, UpdateTuneDoc(tunePath, func(d *schema.TuneDoc) {
		d.Vmctl = schema.VmctlTune{}
		d.Vmselect = schema.VmselectTune{}
	}))
	_, err = os.Stat(tunePath)
	assert.True(t, os.IsNotExist(err), "all-empty doc removes the file")
}

func TestEffectiveVmselectSpecs(t *testing.T) {
	var doc schema.CompiledDoc
	assert.NoError(t, jsonio.ReadFileStrict("../../testdata/golden/plan-compile.json", &doc))

	replicas, image := 2, "vmselect:v2"
	pair, err := EffectiveVmselectSpecs(&doc, schema.VmselectTune{
		Migration: &schema.TuneTarget{Replicas: &replicas, Image: &image},
	})
	assert.NoError(t, err)
	assert.Equal(t, 2, pair.Migration.Replicas)
	assert.Equal(t, "vmselect:v2", pair.Migration.Image)
	assert.Equal(t, "500m", pair.Migration.TuneQuantities().Requests.CPU, "unset resources inherit the plan")
	assert.Equal(t, 1, pair.Candidate.Replicas, "untouched target inherits the plan")

	cpu := "2"
	pair, err = EffectiveVmselectSpecs(&doc, schema.VmselectTune{
		Candidate: &schema.TuneTarget{Resources: &schema.CompiledResources{
			Requests: schema.CompiledResourceQuant{CPU: &cpu},
		}},
	})
	assert.NoError(t, err)
	assert.Equal(t, "2", pair.Candidate.TuneQuantities().Requests.CPU)
	assert.Equal(t, "4Gi", pair.Candidate.TuneQuantities().Limits.Memory, "unset quant fields inherit the plan")
}

func TestRunVmctlTuneOverridesPlanKnobs(t *testing.T) {
	f := func(t *testing.T, tuneContent string, wantFlags ...string) {
		t.Helper()
		dir := t.TempDir()
		manifest := writeMigrationManifestFixture(t, dir)
		if tuneContent != "" {
			writeTuneFile(t, dir, tuneContent)
		}
		rr := &recordingRunner{behavior: func([]string) (int, string, int64) { return 0, "", 0 }}
		err := RunVmctl(context.Background(), VmctlOptions{
			Manifest:          manifest,
			VmRateLimit:       new(100),
			VmConcurrency:     new(2),
			VmctlStepInterval: "day",
			TunePath:          filepath.Join(dir, TuneFile),
		}, rr, combinedActionOutput(os.Stderr))
		assert.NoError(t, err)
		assert.Len(t, rr.calls, 2, "both manifest shards run")
		for _, want := range wantFlags {
			for _, args := range rr.calls {
				assert.Contains(t, args, want, "args=%v", args)
			}
		}
	}

	t.Run("TuneOverridesBoth", func(t *testing.T) {
		f(t, `{"vmctl": {"rateLimitBytes": 999, "concurrency": 7}}`,
			"--vm-rate-limit", "999", "--vm-concurrency", "7")
	})
	t.Run("TuneOverridesOneAndInheritsTheOther", func(t *testing.T) {
		f(t, `{"vmctl": {"concurrency": 7}}`,
			"--vm-rate-limit", "100", "--vm-concurrency", "7")
	})
	t.Run("NoTuneInheritsPlan", func(t *testing.T) {
		f(t, "",
			"--vm-rate-limit", "100", "--vm-concurrency", "2")
	})
	t.Run("EmptyTuneInheritsPlan", func(t *testing.T) {
		f(t, `{}`,
			"--vm-rate-limit", "100", "--vm-concurrency", "2")
	})
}

func TestRunVmctlBadTuneFailsShard(t *testing.T) {
	dir := t.TempDir()
	manifest := writeMigrationManifestFixture(t, dir)
	writeTuneFile(t, dir, `{"vmctl": {"concurrency": 0}}`)

	rr := &recordingRunner{behavior: func([]string) (int, string, int64) {
		t.Error("runner must not be called when the tune file is invalid")
		return 0, "", 0
	}}
	err := RunVmctl(context.Background(), VmctlOptions{
		Manifest:          manifest,
		VmctlStepInterval: "day",
		TunePath:          filepath.Join(dir, TuneFile),
	}, rr, combinedActionOutput(os.Stderr))
	assert.ErrorContains(t, err, "concurrency must be >= 1")
	assert.Empty(t, rr.calls, "no shard may run on a bad tune")
}

func TestRunVmctlPrintsTunedCommand(t *testing.T) {
	dir := t.TempDir()
	manifest := writeMigrationManifestFixture(t, dir)
	writeTuneFile(t, dir, `{"vmctl": {"concurrency": 5}}`)

	var out strings.Builder
	rr := &recordingRunner{behavior: func([]string) (int, string, int64) {
		return 0, "", 0
	}}
	err := RunVmctl(context.Background(), VmctlOptions{
		Manifest:          manifest,
		VmctlStepInterval: "day",
		TunePath:          filepath.Join(dir, TuneFile),
	}, rr, combinedActionOutput(&out))
	assert.NoError(t, err)
	assert.Contains(t, out.String(), "--vm-concurrency 5", "printed command must reflect the tune")
}

func TestPlanKnobs(t *testing.T) {
	f := func(t *testing.T, plan *schema.MigrationLoopPlan, wantRate, wantConcurrency *int) {
		t.Helper()
		rate, concurrency := PlanKnobs(plan)
		assert.Equal(t, wantRate, rate)
		assert.Equal(t, wantConcurrency, concurrency)
	}
	f(t, &schema.MigrationLoopPlan{}, nil, nil)
	f(t, &schema.MigrationLoopPlan{Tenants: []schema.MigrationLoopTenant{
		{Cmds: []schema.MigrationLoopCmd{{Step: "manifest", Action: &schema.MigrationLoopAction{Kind: schema.ActionManifest}}}},
	}}, nil, nil)

	rate, concurrency := new(int), new(int)
	*rate, *concurrency = 512, 8
	f(t, &schema.MigrationLoopPlan{Tenants: []schema.MigrationLoopTenant{
		{Cmds: []schema.MigrationLoopCmd{{Step: "migrate", Action: &schema.MigrationLoopAction{Kind: schema.ActionMigrate, RateLimitBytes: rate, Concurrency: concurrency}}}},
	}}, rate, concurrency)

	// The first migrate action speaks for all; later ones are ignored.
	other := new(int)
	f(t, &schema.MigrationLoopPlan{Tenants: []schema.MigrationLoopTenant{
		{Cmds: []schema.MigrationLoopCmd{{Step: "migrate", Action: &schema.MigrationLoopAction{Kind: schema.ActionMigrate, RateLimitBytes: rate}}}},
		{Cmds: []schema.MigrationLoopCmd{{Step: "migrate", Action: &schema.MigrationLoopAction{Kind: schema.ActionMigrate, RateLimitBytes: other}}}},
	}}, rate, nil)
}
