package reconcile

import (
	"testing"

	"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)

func sp(s string) *string { return &s }

func mib(n int64) int64 { return n * bytesPerMiB }

// desired/actual builders keep the table compact.
func desired(gs ...DesiredGuest) DesiredState {
	m := map[int]DesiredGuest{}
	for _, g := range gs {
		m[g.VMID] = g
	}
	return DesiredState{Guests: m}
}

func actual(gs ...ActualGuest) ActualState {
	m := map[int]ActualGuest{}
	for _, g := range gs {
		m[g.VMID] = g
	}
	return ActualState{Guests: m}
}

func TestPlan_RunStateStartAndStop(t *testing.T) {
	// stopped -> running
	got := Plan(
		desired(DesiredGuest{VMID: 100, Run: RunRunning}),
		actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true}),
		nil)
	mustActions(t, got, Action{VMID: 100, Kind: ActionStart})

	// running -> stopped
	got = Plan(
		desired(DesiredGuest{VMID: 100, Run: RunStopped}),
		actual(ActualGuest{VMID: 100, Run: RunRunning, SpecKnown: true}),
		nil)
	mustActions(t, got, Action{VMID: 100, Kind: ActionStop})

	// already matches -> nothing
	got = Plan(
		desired(DesiredGuest{VMID: 100, Run: RunRunning}),
		actual(ActualGuest{VMID: 100, Run: RunRunning, SpecKnown: true}),
		nil)
	mustActions(t, got)
}

func TestPlan_SpecDrift(t *testing.T) {
	// cores change
	got := Plan(
		desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 4, MemoryBytes: mib(2048)}}),
		actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}),
		nil)
	mustActions(t, got, Action{VMID: 100, Kind: ActionSetConfig, Params: map[string]string{"cores": "4"}})

	// memory change (bytes desired -> MiB param)
	got = Plan(
		desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 2, MemoryBytes: mib(4096)}}),
		actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}),
		nil)
	mustActions(t, got, Action{VMID: 100, Kind: ActionSetConfig, Params: map[string]string{"memory": "4096"}})

	// no spec drift -> nothing
	got = Plan(
		desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 2, MemoryBytes: mib(2048)}}),
		actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}),
		nil)
	mustActions(t, got)
}

func TestPlan_MemoryNonAlignedConverges(t *testing.T) {
	// Note-2 guard: a desired MemoryBytes that is NOT a clean MiB multiple must not
	// cause perpetual drift. We compare in MiB and write the SAME MiB we compared, so it
	// settles in one pass.
	desiredBytes := int64(2049)*bytesPerMiB + 500000 // 2049 MiB + change → floors to 2049
	d := desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 2, MemoryBytes: desiredBytes}})

	// First pass: actual is 2048 MiB → one SetConfig memory=2049.
	got := Plan(d, actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}), nil)
	mustActions(t, got, Action{VMID: 100, Kind: ActionSetConfig, Params: map[string]string{"memory": "2049"}})

	// Apply it: actual becomes 2049 MiB. Re-plan against the SAME desired → no action.
	if got2 := Plan(d, actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2049}), nil); len(got2) != 0 {
		t.Fatalf("non-MiB-aligned memory did not converge (perpetual drift): %+v", got2)
	}
}

func TestPlan_DiskNotReconciled(t *testing.T) {
	// DiskBytes differs but is intentionally not reconciled (pct resize, later slice).
	got := Plan(
		desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 2, MemoryBytes: mib(2048), DiskBytes: 1 << 40}}),
		actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}),
		nil)
	mustActions(t, got)
}

func TestPlan_DescriptionNormalizedNoFalseDrift(t *testing.T) {
	// PVE returns the description with a trailing newline; desired has none. Must NOT
	// be planned as drift.
	got := Plan(
		desired(DesiredGuest{VMID: 100, Description: sp("felhom-managed")}),
		actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Description: "felhom-managed\n"}),
		nil)
	mustActions(t, got)

	// A genuine description change IS planned.
	got = Plan(
		desired(DesiredGuest{VMID: 100, Description: sp("new-desc")}),
		actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Description: "old-desc\n"}),
		nil)
	mustActions(t, got, Action{VMID: 100, Kind: ActionSetConfig, Params: map[string]string{"description": "new-desc"}})
}

func TestPlan_UnmanagedFieldsProduceNothing(t *testing.T) {
	// Run unspecified, Spec nil, Description nil -> the reconciler leaves it alone even
	// though actual differs from "defaults".
	got := Plan(
		desired(DesiredGuest{VMID: 100}),
		actual(ActualGuest{VMID: 100, Run: RunRunning, SpecKnown: true, Cores: 8, MemoryMiB: 9999, Description: "whatever"}),
		nil)
	mustActions(t, got)
}

func TestPlan_SpecUnknownSkipsConfigButKeepsRunState(t *testing.T) {
	// GuestConfig read failed (SpecKnown=false): never write a config we couldn't read,
	// but run-state is still comparable from the list.
	got := Plan(
		desired(DesiredGuest{VMID: 100, Run: RunRunning, Spec: &hub.GuestSpec{Cores: 4, MemoryBytes: mib(4096)}, Description: sp("x")}),
		actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: false}),
		nil)
	mustActions(t, got, Action{VMID: 100, Kind: ActionStart})
}

func TestPlan_DesiredAbsentInActualSkipped(t *testing.T) {
	// A guest desired but not present would be provisioning (slice 7) — not a slice-4
	// action. And a guest present but not desired would be a destroy (gated, slice 10).
	got := Plan(
		desired(DesiredGuest{VMID: 200, Run: RunRunning}),
		actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true}),
		nil)
	mustActions(t, got)
}

func TestPlan_CombinedConfigBeforeRunState(t *testing.T) {
	// cores + memory + description + run change: one SetConfig (all params) THEN the
	// run-state action, both on the same vmid (the queue serializes them).
	got := Plan(
		desired(DesiredGuest{VMID: 100, Run: RunRunning,
			Spec: &hub.GuestSpec{Cores: 4, MemoryBytes: mib(4096)}, Description: sp("new")}),
		actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048, Description: "old\n"}),
		nil)
	if len(got) != 2 {
		t.Fatalf("want 2 actions, got %d: %+v", len(got), got)
	}
	if got[0].Kind != ActionSetConfig {
		t.Errorf("first action should be SetConfig, got %s", got[0].Kind)
	}
	for _, k := range []string{"cores", "memory", "description"} {
		if _, ok := got[0].Params[k]; !ok {
			t.Errorf("SetConfig params missing %q: %v", k, got[0].Params)
		}
	}
	if got[1].Kind != ActionStart {
		t.Errorf("second action should be Start, got %s", got[1].Kind)
	}
}

func TestPlan_EmptyDesiredNoActions(t *testing.T) {
	// The slice-4 production case: empty desired -> zero actions regardless of actual.
	got := Plan(
		DesiredState{Guests: map[int]DesiredGuest{}},
		actual(ActualGuest{VMID: 100, Run: RunRunning, SpecKnown: true}),
		nil)
	mustActions(t, got)
}

func TestPlan_DeterministicVMIDOrder(t *testing.T) {
	got := Plan(
		desired(
			DesiredGuest{VMID: 300, Run: RunRunning},
			DesiredGuest{VMID: 100, Run: RunRunning},
			DesiredGuest{VMID: 200, Run: RunRunning},
		),
		actual(
			ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true},
			ActualGuest{VMID: 200, Run: RunStopped, SpecKnown: true},
			ActualGuest{VMID: 300, Run: RunStopped, SpecKnown: true},
		),
		nil)
	if len(got) != 3 || got[0].VMID != 100 || got[1].VMID != 200 || got[2].VMID != 300 {
		t.Fatalf("actions not sorted by vmid: %+v", got)
	}
}

// mustActions asserts the planned actions equal want (vmid+kind+params), ignoring
// Reason (debug-only).
func mustActions(t *testing.T, got []Action, want ...Action) {
	t.Helper()
	if len(got) != len(want) {
		t.Fatalf("got %d actions, want %d: %+v", len(got), len(want), got)
	}
	for i := range want {
		if got[i].VMID != want[i].VMID || got[i].Kind != want[i].Kind {
			t.Errorf("action[%d] = {vmid:%d kind:%s}, want {vmid:%d kind:%s}",
				i, got[i].VMID, got[i].Kind, want[i].VMID, want[i].Kind)
		}
		if !sameParams(got[i].Params, want[i].Params) {
			t.Errorf("action[%d] params = %v, want %v", i, got[i].Params, want[i].Params)
		}
	}
}

func sameParams(a, b map[string]string) bool {
	if len(a) != len(b) {
		return false
	}
	for k, v := range b {
		if a[k] != v {
			return false
		}
	}
	return true
}
