package cli

import (
	"bytes"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/kardianos/service"
)

// fakeService implements the parts of service.Service that rollback uses. Any other
// method panics, which keeps accidental dependencies visible.
type fakeService struct {
	service.Service

	stopErr    error
	stopCalls  int
	statuses   []service.Status // consumed one per Status() call; the last repeats
	statusErr  error
	onStopCall func()
}

func (f *fakeService) Stop() error {
	f.stopCalls++
	if f.onStopCall != nil {
		f.onStopCall()
	}
	return f.stopErr
}

func (f *fakeService) Status() (service.Status, error) {
	if f.statusErr != nil {
		return service.StatusUnknown, f.statusErr
	}
	if len(f.statuses) == 0 {
		return service.StatusStopped, nil
	}
	st := f.statuses[0]
	if len(f.statuses) > 1 {
		f.statuses = f.statuses[1:]
	}
	return st, nil
}

func TestStopServiceAndWait(t *testing.T) {
	tests := []struct {
		name    string
		svc     *fakeService
		timeout time.Duration
		wantErr bool
	}{
		{
			name:    "stops after a few polls",
			svc:     &fakeService{statuses: []service.Status{service.StatusRunning, service.StatusRunning, service.StatusStopped}},
			timeout: 5 * time.Second,
		},
		{
			name:    "already stopped",
			svc:     &fakeService{statuses: []service.Status{service.StatusStopped}},
			timeout: 5 * time.Second,
		},
		{
			// A stop request that errors is not fatal on its own: the process may be
			// exiting anyway, so the status poll decides.
			name:    "stop errors but service is stopped",
			svc:     &fakeService{stopErr: errors.New("already stopped"), statuses: []service.Status{service.StatusStopped}},
			timeout: 5 * time.Second,
		},
		{
			name:    "not installed",
			svc:     &fakeService{statusErr: service.ErrNotInstalled},
			timeout: 5 * time.Second,
		},
		{
			// The process never exits. Rollback must be told so, because modifying a
			// running executable is what produced "Access is denied".
			name:    "never stops",
			svc:     &fakeService{statuses: []service.Status{service.StatusRunning}},
			timeout: time.Millisecond,
			wantErr: true,
		},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			err := stopServiceAndWait(tc.svc, tc.timeout)
			if tc.wantErr && err == nil {
				t.Fatal("expected an error, got nil")
			}
			if !tc.wantErr && err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if tc.svc.stopCalls != 1 {
				t.Errorf("Stop() called %d times, want 1", tc.svc.stopCalls)
			}
		})
	}
}

func TestRemoveBinaryWithRetry(t *testing.T) {
	t.Run("removes an existing file", func(t *testing.T) {
		path := filepath.Join(t.TempDir(), "ctrld")
		if err := os.WriteFile(path, []byte("binary"), 0o755); err != nil {
			t.Fatal(err)
		}
		if err := removeBinaryWithRetry(path, time.Second); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
			t.Errorf("file still exists after removal: %v", err)
		}
	})

	t.Run("missing file is not an error", func(t *testing.T) {
		path := filepath.Join(t.TempDir(), "absent")
		if err := removeBinaryWithRetry(path, time.Second); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})

	t.Run("gives up and reports when the path cannot be removed", func(t *testing.T) {
		// A non-empty directory stands in for a locked executable: os.Remove keeps
		// failing, so the retry loop must surface the error rather than hang.
		dir := filepath.Join(t.TempDir(), "locked")
		if err := os.Mkdir(dir, 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(filepath.Join(dir, "child"), nil, 0o644); err != nil {
			t.Fatal(err)
		}
		if err := removeBinaryWithRetry(dir, time.Millisecond); err == nil {
			t.Fatal("expected an error for a path that cannot be removed")
		}
	})
}

func TestBinaryVersion(t *testing.T) {
	t.Run("reports the version", func(t *testing.T) {
		t.Setenv(envFakeVersionOutput, cliName+" version dev-94fbd3f")
		got, err := binaryVersion(os.Args[0])
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if got != "dev-94fbd3f" {
			t.Errorf("binaryVersion() = %q, want %q", got, "dev-94fbd3f")
		}
	})

	t.Run("rejects a binary that prints no version", func(t *testing.T) {
		// The incident's ctrld.exe_previous: the file exists and runs, but produces no
		// version output. Restoring it would have replaced a hung service with one
		// that cannot start at all.
		t.Setenv(envFakeVersionOutput, envFakeVersionSilent)
		if _, err := binaryVersion(os.Args[0]); err == nil {
			t.Fatal("expected an error for a binary with no version output")
		}
	})

	t.Run("rejects a missing binary", func(t *testing.T) {
		if _, err := binaryVersion(filepath.Join(t.TempDir(), "absent")); err == nil {
			t.Fatal("expected an error for a missing binary")
		}
	})
}

// stubBinaryVersion makes the version probe report ver for any path, so a rollback
// test does not have to stage a runnable executable.
//
// Staging one is not portable: oldBin is bin+"_previous", so a fixture named "ctrld"
// yields the extension-less "ctrld_previous", which Windows refuses to execute
// ("executable file not found in %PATH%"), and a symlink to the test binary needs a
// privilege Windows does not grant by default. The probe itself is covered against the
// real test binary in TestBinaryVersion; these tests are about rollback's ordering.
func stubBinaryVersion(t *testing.T, ver string, err error) {
	t.Helper()
	prev := binaryVersionFn
	binaryVersionFn = func(string) (string, error) { return ver, err }
	t.Cleanup(func() { binaryVersionFn = prev })
}

func TestRollbackToPreviousBinaryStopsBeforeTouchingTheBinary(t *testing.T) {
	dir := t.TempDir()
	bin := filepath.Join(dir, "ctrld")
	oldBin := bin + oldBinSuffix
	if err := os.WriteFile(bin, []byte("replacement"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(oldBin, []byte("previous"), 0o755); err != nil {
		t.Fatal(err)
	}
	stubBinaryVersion(t, "dev-a75d669", nil)

	// The invariant: when stop runs, the replacement's executable is still untouched.
	// Reversing these two is exactly the "Access is denied" defect.
	var stopped bool
	var binExistedAtStop bool
	stop := func() error {
		stopped = true
		_, err := os.Stat(bin)
		binExistedAtStop = err == nil
		return nil
	}
	restarted := false
	restart := func() bool { restarted = true; return true }

	if err := rollbackToPreviousBinary(bin, oldBin, stop, restart); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !stopped {
		t.Error("rollback did not stop the service")
	}
	if !binExistedAtStop {
		t.Error("the binary was modified before the service was stopped")
	}
	if !restarted {
		t.Error("rollback did not restart the service")
	}
	if _, err := os.Stat(oldBin); !errors.Is(err, os.ErrNotExist) {
		t.Errorf("previous binary was not moved into place: %v", err)
	}
	if _, err := os.Stat(bin); err != nil {
		t.Errorf("restored binary is missing: %v", err)
	}
}

func TestRollbackToPreviousBinaryKeepsUnusablePrevious(t *testing.T) {
	dir := t.TempDir()
	bin := filepath.Join(dir, "ctrld")
	oldBin := bin + oldBinSuffix
	if err := os.WriteFile(bin, []byte("replacement"), 0o755); err != nil {
		t.Fatal(err)
	}
	// A previous binary that exists but does not report a version, as in the incident.
	if err := os.WriteFile(oldBin, []byte("not a working binary"), 0o755); err != nil {
		t.Fatal(err)
	}
	// Stubbed rather than left to the real probe: that would fail here for the right
	// reason on unix (not an executable) but the wrong one on Windows (the fixture's
	// name has no extension), so the assertion would not be about usability at all.
	stubBinaryVersion(t, "", errors.New("unexpected --version output"))

	stopped := false
	restarted := false
	err := rollbackToPreviousBinary(bin, oldBin,
		func() error { stopped = true; return nil },
		func() bool { restarted = true; return true },
	)
	if err == nil {
		t.Fatal("expected an error when the previous binary is unusable")
	}
	if !stopped {
		t.Error("the service must still be stopped: a broken replacement holds enforcement")
	}
	if restarted {
		t.Error("must not restart the service with an unusable binary")
	}
	// Nothing was swapped, and the previous file is kept for inspection.
	if _, err := os.Stat(oldBin); err != nil {
		t.Errorf("unusable previous binary was not preserved: %v", err)
	}
	if _, err := os.Stat(bin); err != nil {
		t.Errorf("installed binary was removed despite having nothing to restore: %v", err)
	}
}

func TestRollbackToPreviousBinaryAbortsWhenStopFails(t *testing.T) {
	dir := t.TempDir()
	bin := filepath.Join(dir, "ctrld")
	oldBin := bin + oldBinSuffix
	for _, p := range []string{bin, oldBin} {
		if err := os.WriteFile(p, []byte("binary"), 0o755); err != nil {
			t.Fatal(err)
		}
	}

	stopErr := errors.New("service did not stop within 30s")
	err := rollbackToPreviousBinary(bin, oldBin,
		func() error { return stopErr },
		func() bool { t.Error("must not restart after a failed stop"); return false },
	)
	if !errors.Is(err, stopErr) {
		t.Fatalf("error = %v, want %v", err, stopErr)
	}
	// The executable of a process that may still be running must be left alone.
	if _, err := os.Stat(bin); err != nil {
		t.Errorf("binary was modified even though the stop failed: %v", err)
	}
}

// TestVersionOutputParsesThroughRollbackProbe ties the "--version" output the root
// command actually produces to the parser rollback reads it with.
//
// These are two halves of one contract that live in different files: Cobra renders
// "<Use> version <Version>", and binaryVersion cuts a prefix off it. Renaming the
// client moved the first half; if the second half had kept its literal, every
// upgrade would have logged "unknown version" and - the part that matters -
// rollbackToPreviousBinary would have judged a perfectly good previous binary
// "not usable" and left the host stopped with the broken one installed.
//
// The version output is taken from the real root command rather than assembled
// here, so a future change to the name, the template, or the parser has to keep
// them agreeing.
func TestVersionOutputParsesThroughRollbackProbe(t *testing.T) {
	rootCmd := initCLI()
	rootCmd.SetVersionTemplate(rootCmd.VersionTemplate())

	var out bytes.Buffer
	rootCmd.SetOut(&out)
	rootCmd.SetErr(&out)
	rootCmd.SetArgs([]string{"--version"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("running --version: %v", err)
	}

	got := out.String()
	if strings.TrimSpace(got) == "" {
		t.Fatal("--version produced no output")
	}
	ver, ok := parseVersionOutput(got)
	if !ok {
		t.Fatalf("the version probe cannot parse the root command's own --version output %q; "+
			"rollback would reject a working previous binary as unusable", strings.TrimSpace(got))
	}
	if ver != appVersion {
		t.Errorf("parsed version = %q, want %q", ver, appVersion)
	}
}

// TestParseVersionOutput covers the shapes the probe must accept and reject. The
// rejected ones are what a genuinely broken previous binary produces - the
// incident's ctrld.exe_previous printed nothing at all - and rollback depends on
// telling those apart from a healthy binary under a new name.
func TestParseVersionOutput(t *testing.T) {
	tests := []struct {
		name string
		out  string
		want string
		ok   bool
	}{
		{"current identity", cliName + " version v1.0.0", "v1.0.0", true},
		{"trailing newline", cliName + " version v1.0.0\n", "v1.0.0", true},
		{"dev build", cliName + " version dev-94fbd3f", "dev-94fbd3f", true},
		// The pre-rename identity: a v1-line binary is not a valid rollback target
		// for this client, and must not be read as one.
		{"previous identity", "ctrld version v1.3.5", "", false},
		{"no output", "", "", false},
		{"unrelated output", "some other program", "", false},
	}
	for _, tc := range tests {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got, ok := parseVersionOutput(tc.out)
			if ok != tc.ok {
				t.Fatalf("parseVersionOutput(%q) ok = %v, want %v", tc.out, ok, tc.ok)
			}
			if got != tc.want {
				t.Errorf("parseVersionOutput(%q) = %q, want %q", tc.out, got, tc.want)
			}
		})
	}
}
