package daemon

import (
	"context"
	"errors"
	"io"
	"log/slog"
	"strings"
	"testing"
)

// TestRunStartSteps_RollsBackInReverseOnFailure pins the driver
// contract at the heart of commit 1's refactor: on a step failure
// (a) every step that succeeded BEFORE the failing one gets its
// undo fired in reverse order; (b) the failing step's undo also
// fires, because steps may acquire partial state before returning
// err; (c) the final error wraps both the run error and any
// rollback errors via errors.Join.
func TestRunStartSteps_RollsBackInReverseOnFailure(t *testing.T) {
	s := &VMService{}
	op := &operationLog{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
	sc := &startContext{}

	var events []string
	record := func(label string) func(context.Context, *startContext) error {
		return func(context.Context, *startContext) error {
			events = append(events, label)
			return nil
		}
	}
	recordErr := func(label string, err error) func(context.Context, *startContext) error {
		return func(context.Context, *startContext) error {
			events = append(events, label)
			return err
		}
	}

	steps := []startStep{
		{name: "first", run: record("run-first"), undo: record("undo-first")},
		{name: "second", run: record("run-second"), undo: record("undo-second")},
		{name: "third", run: recordErr("run-third", errors.New("boom")), undo: record("undo-third")},
		{name: "fourth", run: record("run-fourth"), undo: record("undo-fourth")},
	}

	err := s.runStartSteps(context.Background(), op, sc, steps)
	if err == nil || !strings.Contains(err.Error(), "boom") {
		t.Fatalf("runStartSteps err = %v, want containing 'boom'", err)
	}

	want := []string{
		// Forward run: first, second, third (fails — fourth never runs).
		"run-first", "run-second", "run-third",
		// Reverse undo: third, second, first. Fourth never ran so no undo-fourth.
		"undo-third", "undo-second", "undo-first",
	}
	if len(events) != len(want) {
		t.Fatalf("events length = %d, want %d:\n  got:  %v\n  want: %v", len(events), len(want), events, want)
	}
	for i := range want {
		if events[i] != want[i] {
			t.Fatalf("events[%d] = %q, want %q\n  got:  %v\n  want: %v", i, events[i], want[i], events, want)
		}
	}
}

// TestRunStartSteps_SkipsNilUndos proves the optional-undo contract:
// steps without teardown obligations leave `undo` nil and the driver
// must silently skip them during rollback rather than panicking.
func TestRunStartSteps_SkipsNilUndos(t *testing.T) {
	s := &VMService{}
	op := &operationLog{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
	sc := &startContext{}

	var undoCalls []string
	undo := func(label string) func(context.Context, *startContext) error {
		return func(context.Context, *startContext) error {
			undoCalls = append(undoCalls, label)
			return nil
		}
	}
	noop := func(context.Context, *startContext) error { return nil }

	steps := []startStep{
		{name: "has-undo", run: noop, undo: undo("has-undo")},
		{name: "no-undo", run: noop}, // undo nil intentionally
		{name: "failing", run: func(context.Context, *startContext) error { return errors.New("x") }, undo: undo("failing")},
	}

	if err := s.runStartSteps(context.Background(), op, sc, steps); err == nil {
		t.Fatal("runStartSteps err = nil, want failure")
	}

	// Rollback order: failing (acquired state, so its undo runs), no-undo
	// (skipped — nil), has-undo.
	want := []string{"failing", "has-undo"}
	if len(undoCalls) != len(want) || undoCalls[0] != want[0] || undoCalls[1] != want[1] {
		t.Fatalf("undo calls = %v, want %v", undoCalls, want)
	}
}

// TestRunStartSteps_JoinsRollbackErrors asserts that undo errors are
// joined onto the original run error rather than hiding it — the
// caller must always see the root cause ("boom") even when the
// rollback path itself is messy.
func TestRunStartSteps_JoinsRollbackErrors(t *testing.T) {
	s := &VMService{}
	op := &operationLog{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
	sc := &startContext{}

	rootErr := errors.New("boom")
	undoErr := errors.New("undo-fail")

	steps := []startStep{
		{
			name: "ok",
			run:  func(context.Context, *startContext) error { return nil },
			undo: func(context.Context, *startContext) error { return undoErr },
		},
		{
			name: "fail",
			run:  func(context.Context, *startContext) error { return rootErr },
		},
	}

	err := s.runStartSteps(context.Background(), op, sc, steps)
	if err == nil {
		t.Fatal("err = nil, want joined error")
	}
	if !errors.Is(err, rootErr) {
		t.Fatalf("err does not wrap rootErr; got: %v", err)
	}
	if !errors.Is(err, undoErr) {
		t.Fatalf("err does not wrap undoErr; got: %v", err)
	}
}

// TestRunStartSteps_HappyPathNoRollback confirms that when every
// step's run returns nil, no undo fires — rollback is strictly a
// failure-path concern.
func TestRunStartSteps_HappyPathNoRollback(t *testing.T) {
	s := &VMService{}
	op := &operationLog{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
	sc := &startContext{}

	var undoCalled bool
	steps := []startStep{
		{
			name: "a",
			run:  func(context.Context, *startContext) error { return nil },
			undo: func(context.Context, *startContext) error { undoCalled = true; return nil },
		},
		{
			name: "b",
			run:  func(context.Context, *startContext) error { return nil },
		},
	}

	if err := s.runStartSteps(context.Background(), op, sc, steps); err != nil {
		t.Fatalf("runStartSteps err = %v, want nil", err)
	}
	if undoCalled {
		t.Fatal("undo fired on happy path — rollback must only run on failure")
	}
}
