package cli

import (
	"encoding/json"
	"io"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"

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

func writeSamplePlan(t *testing.T, path string) {
	t.Helper()
	planJSON := `{
  "sourceReplicaCount": 70,
  "targetReplicaCount": 30,
  "namespace": "observability",
  "vmselects": {
    "image": "victoriametrics/vmselect:v1.122.2-cluster",
    "migration": {
      "replicas": 1,
      "resources": {
        "requests": {"cpu": "4", "memory": "32Gi"},
        "limits": {"cpu": "8", "memory": "64Gi"}
      }
    },
    "candidate": {
      "replicas": 1,
      "resources": {
        "requests": {"cpu": "4", "memory": "32Gi"},
        "limits": {"cpu": "8", "memory": "64Gi"}
      }
    }
  },
  "region": "prod",
  "retentionWindow": {"start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z"},
  "shardStepInterval": "1d",
  "vmctl": {"rateLimitBytes": 1048576, "concurrency": 2, "stepInterval": "hour"},
  "steps": {
    "prepare": {
      "items": [
        {"name": "preflight", "desc": "check cluster", "command": ["kubectl describe"]}
      ]
    },
    "migration": {
      "items": [
        {"name": "run", "desc": "dispatch the loop", "command": ["curl run"]}
      ]
    },
    "decommission": {
      "items": [
        {"name": "cleanup", "desc": "cleanup", "command": ["kubectl delete"]}
      ]
    }
  }
}`
	assert.NoError(t, os.WriteFile(path, []byte(planJSON), 0o644))
}

func TestPlanLoad(t *testing.T) {
	planPath := filepath.Join(t.TempDir(), "plan.json")
	writeSamplePlan(t, planPath)

	cfg, stages, err := plan.Load(planPath)
	assert.NoError(t, err)
	assert.Equal(t, 70, cfg.SourceReplicaCount)
	assert.Equal(t, 30, cfg.TargetReplicaCount)
	assert.Equal(t, "hour", cfg.VmctlStepInterval)
	assert.Len(t, stages[plan.StagePrepare], 1)
}

func TestRenderCompiledAndMigrationLoop(t *testing.T) {
	dir := t.TempDir()
	planPath := filepath.Join(dir, "plan.json")
	writeSamplePlan(t, planPath)
	cfg, stages, err := plan.Load(planPath)
	assert.NoError(t, err)
	cfg.Tenants = []string{"1001:0", "1002:0"}
	loopPlan, err := plan.BuildMigrationLoop(cfg)
	assert.NoError(t, err)
	compilePath, err := plan.WriteCompiled(planPath, "", cfg, stages)
	assert.NoError(t, err)
	loopPath, err := plan.WriteLoopDocument(dir, loopPlan)
	assert.NoError(t, err)

	assert.Equal(t, filepath.Join(dir, "plan-compile.json"), compilePath)
	assert.Equal(t, filepath.Join(dir, "migration-loop.json"), loopPath)

	assert.Contains(t, string(mustReadFileLocal(t, compilePath)), "dispatch the loop")

	var loop schema.MigrationLoopPlan
	assert.NoError(t, json.Unmarshal(mustReadFileLocal(t, loopPath), &loop))
	assert.Len(t, loop.Tenants, 2)
	assert.Equal(t, "1001:0", loop.Tenants[0].Name)
	assert.Equal(t, "tenants-1001-0-status.json", loop.Tenants[0].StatusFile)
	assert.Equal(t, "tenants-1001-0.log", loop.Tenants[0].LogFile)
	assert.Len(t, loop.Tenants[0].Cmds, 3)
	assert.Equal(t, "manifest", loop.Tenants[0].Cmds[0].Step)
}

func TestWriteCompiledHonorsCompileOut(t *testing.T) {
	dir := t.TempDir()
	planPath := filepath.Join(dir, "plan.json")
	writeSamplePlan(t, planPath)
	cfg, stages, err := plan.Load(planPath)
	assert.NoError(t, err)
	custom := filepath.Join(dir, "custom-compile.json")
	compilePath, err := plan.WriteCompiled(planPath, custom, cfg, stages)
	assert.NoError(t, err)
	assert.Equal(t, custom, compilePath)
	_, err = os.Stat(custom)
	assert.NoError(t, err, "custom compile path not written")
}

func TestPlanLoadRejectsNonPositiveSourceReplicaCount(t *testing.T) {
	planPath := filepath.Join(t.TempDir(), "plan.json")
	bad := `{
  "namespace": "observability", "region": "prod",
  "sourceReplicaCount": 0, "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": []}, "decommission": {"items": []}}
}`
	assert.NoError(t, os.WriteFile(planPath, []byte(bad), 0o644))
	_, _, err := plan.Load(planPath)
	assert.ErrorContains(t, err, "sourceReplicaCount")
}

func TestPlanLoadAggregatesMissingFields(t *testing.T) {
	planPath := filepath.Join(t.TempDir(), "plan.json")
	bad := `{
  "sourceReplicaCount": 5, "targetReplicaCount": 3, "shardStepInterval": "1d",
  "steps": {"prepare": {"items": []}, "migration": {"items": []}, "decommission": {"items": []}}
}`
	assert.NoError(t, os.WriteFile(planPath, []byte(bad), 0o644))
	_, _, err := plan.Load(planPath)
	assert.Error(t, err)
	for _, want := range []string{"namespace", "region"} {
		assert.Contains(t, err.Error(), want)
	}
}

func TestPlanLoadRejectsInvalidStepName(t *testing.T) {
	planPath := filepath.Join(t.TempDir(), "plan.json")
	bad := `{
  "sourceReplicaCount": 70, "targetReplicaCount": 30,
  "namespace": "n",
  "vmselects": {"image": "im", "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"}}}},
  "region": "r",
  "retentionWindow": {"start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z"},
  "shardStepInterval": "1d",
  "steps": {
    "prepare": {"items": [{"name": "bogus-step", "command": ["x"]}]},
    "migration": {"items": []},
    "decommission": {"items": []}
  }
}`
	assert.NoError(t, os.WriteFile(planPath, []byte(bad), 0o644))
	_, _, err := plan.Load(planPath)
	assert.ErrorContains(t, err, "unknown step")
}

func TestSubstituteHandlesUnknownKey(t *testing.T) {
	step := plan.Step{Name: "x", Desc: "hello {{.missing}}", Command: nil}
	// substitute is unexported; exercise it via RenderCompiled.
	cfg := &plan.Config{
		SourceReplicaCount: 2,
		TargetReplicaCount: 1,
		Region:             "r",
		Namespace:          "n",
		VmselectImage:      "im",
		WindowStart:        "x",
		WindowEnd:          "y",
		ShardStepInterval:  "1d",
		VmctlStepInterval:  "day",
	}
	stages := plan.Stages{plan.StagePrepare: []plan.Step{step}}
	_, err := plan.RenderCompiled(cfg, stages)
	assert.Error(t, err)
}

func TestInitPlanWritesTemplate(t *testing.T) {
	out := filepath.Join(t.TempDir(), "plan.json")
	args := &InitPlanArgs{Output: out}

	// Capture os.Stdout via a pipe drained on a goroutine, so a large stdout
	// can't fill the OS pipe buffer and deadlock the write.
	r, w, err := os.Pipe()
	assert.NoError(t, err)
	orig := os.Stdout
	os.Stdout = w
	captured := make(chan string, 1)
	go func() {
		data, _ := io.ReadAll(r)
		captured <- string(data)
	}()
	// Restore the global and close both pipe ends however the test exits, so
	// later tests aren't left writing into a dead pipe and no FDs leak.
	t.Cleanup(func() {
		os.Stdout = orig
		_ = w.Close()
		_ = r.Close()
	})

	assert.NoError(t, runInitPlan(args, os.Stdout))
	assert.NoError(t, w.Close()) // EOF → drain goroutine finishes
	os.Stdout = orig

	_, err = os.Stat(out)
	assert.NoError(t, err, "plan.json not written")
	assert.Contains(t, <-captured, "plan init: wrote "+out)

	// A map round-trip here would alphabetize the keys.
	data, err := os.ReadFile(out)
	assert.NoError(t, err)
	_, steps, ok := strings.Cut(string(data), `"steps"`)
	assert.True(t, ok)
	i := strings.Index(steps, `"prepare"`)
	j := strings.Index(steps, `"migration"`)
	k := strings.Index(steps, `"decommission"`)
	assert.True(t, 0 <= i && i < j && j < k, "steps must be ordered prepare/migration/decommission")

	assert.Error(t, runInitPlan(args, os.Stdout), "second plan init should refuse overwrite")
}

// mustReadFileLocal reads a file, failing the test on error.
func mustReadFileLocal(t *testing.T, path string) []byte {
	t.Helper()
	b, err := os.ReadFile(path)
	assert.NoError(t, err)
	return b
}
