package plan

import (
	"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"
	"github.com/tidbcloud/observability/vm-migration/internal/tenants"
)

var goldenPlanPath = filepath.Join("..", "..", "testdata", "golden", "plan-input.json")

// loadGoldenCompiled mirrors plan.Compile: golden plan + golden tenant
// inventory, normalized the same way.
func loadGoldenCompiled(t *testing.T) (*Config, Stages) {
	t.Helper()
	cfg, stages, err := Load(goldenPlanPath)
	assert.NoError(t, err)
	ids, err := tenants.ReadFile(filepath.Join("..", "..", "testdata", "golden", "plan-tenants.json"))
	assert.NoError(t, err)
	normalized, _, err := NormalizeTenantIDs(ids)
	assert.NoError(t, err)
	cfg.Tenants = normalized
	return cfg, stages
}

func TestRenderCompiledGolden(t *testing.T) {
	cfg, stages := loadGoldenCompiled(t)
	got, err := RenderCompiled(cfg, stages)
	assert.NoError(t, err)
	want, err := os.ReadFile(filepath.Join("..", "..", "testdata", "golden", "plan-compile.json"))
	assert.NoError(t, err)
	assert.Equal(t, string(want), got, "RenderCompiled output drift vs golden fixture")
}

// A regenerated golden fixture would silently accept the stages back in
// alphabetical order.
func TestRenderCompiledStepsInWorkflowOrder(t *testing.T) {
	cfg, stages := loadGoldenCompiled(t)
	got, err := RenderCompiled(cfg, stages)
	assert.NoError(t, err)

	_, block, ok := strings.Cut(got, `"steps"`)
	assert.True(t, ok)
	idx := func(key string) int {
		i := strings.Index(block, key)
		assert.GreaterOrEqual(t, i, 0, "missing %s", key)
		return i
	}
	assert.Less(t, idx(`"prepare"`), idx(`"migration"`))
	assert.Less(t, idx(`"migration"`), idx(`"decommission"`))
}

func TestRenderMigrationLoopGolden(t *testing.T) {
	cfg, _ := loadGoldenCompiled(t)
	p, err := BuildMigrationLoop(cfg)
	assert.NoError(t, err)
	got, err := jsonio.MarshalIndent(p)
	assert.NoError(t, err)
	want, err := os.ReadFile(filepath.Join("..", "..", "testdata", "golden", "migration-loop.json"))
	assert.NoError(t, err)
	assert.Equal(t, string(want), string(got), "BuildMigrationLoop output drift vs golden fixture")
}

func TestBuildMigrationLoopUsesTypedMigrateStep(t *testing.T) {
	cfg, _ := loadGoldenCompiled(t)
	p, err := BuildMigrationLoop(cfg)
	assert.NoError(t, err)
	var migrateStep *schema.MigrationLoopCmd
	for _, step := range p.Tenants[0].Cmds {
		if step.Step == "migrate" {
			migrateStep = &step
			break
		}
	}
	assert.NotNil(t, migrateStep, "migrate step not found")
	assert.NotNil(t, migrateStep.Action, "migrate step must use typed in-process configuration")
	assert.Equal(t, schema.ActionMigrate, migrateStep.Action.Kind)
	assert.Equal(t, "tenants-1001-0-manifest.json", migrateStep.Action.Manifest)
	assert.Equal(t, cfg.VmctlStepInterval, migrateStep.Action.StepInterval)
}

// basePlanJSON renders a minimal valid plan skeleton with the given
// migration items and empty prepare/decommission stages.
func basePlanJSON(migrationItems string) string {
	return `{
  "namespace": "observability",
  "region": "prod",
  "sourceReplicaCount": 5,
  "targetReplicaCount": 3,
  "shardStepInterval": "1d",
  "vmselects": {"image": "img", "migration": {"replicas": 1, "resources": {"requests": {"cpu": "1", "memory": "1Gi"}, "limits": {"cpu": "2", "memory": "2Gi"}}}, "candidate": {"replicas": 1, "resources": {"requests": {"cpu": "1", "memory": "1Gi"}, "limits": {"cpu": "2", "memory": "2Gi"}}}},
  "retentionWindow": {"start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z"},
  "steps": {
    "prepare": {"items": []},
    "migration": {"items": [` + migrationItems + `]},
    "decommission": {"items": []}
  }
}`
}

func TestValidateRejectsBadTimeFields(t *testing.T) {
	valid := basePlanJSON("")
	cases := []struct {
		name   string
		oldNew [2]string
		field  string
	}{
		{"BadStart", [2]string{`"start": "2026-01-01T00:00:00Z"`, `"start": "yesterday"`}, "retentionWindow.start"},
		{"BadEnd", [2]string{`"end": "2026-01-02T00:00:00Z"`, `"end": "TBD"`}, "retentionWindow.end"},
		{"EndBeforeStart", [2]string{`"end": "2026-01-02T00:00:00Z"`, `"end": "2025-12-31T00:00:00Z"`}, "end after start"},
		{"EndEqualsStart", [2]string{`"end": "2026-01-02T00:00:00Z"`, `"end": "2026-01-01T00:00:00Z"`}, "end after start"},
		{"BadStepInterval", [2]string{`"shardStepInterval": "1d"`, `"shardStepInterval": "daily"`}, "shardStepInterval"},
		{"ZeroStepInterval", [2]string{`"shardStepInterval": "1d"`, `"shardStepInterval": "0s"`}, "shardStepInterval"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			raw := strings.Replace(valid, tc.oldNew[0], tc.oldNew[1], 1)
			if raw == valid {
				t.Fatalf("fixture replace %q not found", tc.oldNew[0])
			}
			_, _, err := LoadBytes([]byte(raw))
			assert.ErrorContains(t, err, tc.field)
		})
	}
}

func TestValidateRejectsUnknownStep(t *testing.T) {
	planPath := filepath.Join(t.TempDir(), "plan.json")
	bad := strings.Replace(basePlanJSON(""),
		`"prepare": {"items": []}`,
		`"prepare": {"items": [{"name": "bogus-step", "command": ["x"]}]}`, 1)
	assert.NoError(t, os.WriteFile(planPath, []byte(bad), 0o644))
	_, _, err := Load(planPath)
	assert.Error(t, err)
	assert.ErrorContains(t, err, "unknown step")
}

func TestLoadErrors(t *testing.T) {
	t.Run("MissingPlan", func(t *testing.T) {
		missing := filepath.Join(t.TempDir(), "absent.json")
		_, _, err := Load(missing)
		assert.ErrorContains(t, err, missing)
		assert.ErrorContains(t, err, "plan init")
	})

	t.Run("DirectoryPath", func(t *testing.T) {
		_, _, err := Load(t.TempDir())
		assert.Error(t, err)
	})

	t.Run("BadJSON", func(t *testing.T) {
		path := filepath.Join(t.TempDir(), "bad.json")
		assert.NoError(t, os.WriteFile(path, []byte("{oops"), 0o644))
		_, _, err := Load(path)
		assert.Error(t, err)
	})
}
