package dispatcher

import (
	"context"
	"os"
	"reflect"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/desktop/internal/agent"
	"github.com/tidbcloud/AgentTeam/desktop/internal/agent/drivercontract"
	"github.com/tidbcloud/AgentTeam/desktop/internal/agent/runtimeconfig"
	"github.com/tidbcloud/AgentTeam/desktop/internal/api/exec"
	"github.com/tidbcloud/AgentTeam/desktop/internal/token"
	"github.com/tidbcloud/AgentTeam/desktop/internal/types"
	"github.com/tidbcloud/AgentTeam/desktop/internal/wire/apiclient"
)

func TestExecResolveActivatesProductionAgentTeamExecConfigAndPrompt(t *testing.T) {
	t.Setenv("AGENTTEAM_CONFIG_DIR", t.TempDir())
	_ = initTokenForTest(t)

	d := mustNewDispatcher("http://127.0.0.1:3456")
	t.Cleanup(d.Close)
	d.serverConnections.Register(execTestServerConnection{mcpTestServerConnection: mcpTestServerConnection{id: "agentteam-main"}})
	plc := execTestLaunchContext("agentteam-main")
	plc.agent.ID = "agent-1"
	plc.mode = promptModeSession

	require.NoError(t, d.resolve(plc))
	require.NotNil(t, plc.startCfg.Exec)
	require.NotEmpty(t, plc.startCfg.Exec.AuthorizationID)
	require.NotEmpty(t, plc.startCfg.Exec.TokenFile)
	require.Equal(t, plc.startCfg.Exec.TokenFile, plc.startCfg.Env[exec.EnvTokenFile])
	require.NotContains(t, plc.startCfg.Env, "AGENTTEAM_EXEC_TOKEN")
	require.NotEmpty(t, readExecCredentialForTest(t, plc.startCfg.Exec.TokenFile))
	require.NotEmpty(t, plc.startCfg.Exec.BaseURL)
	require.Equal(t, plc.startCfg.Exec.BaseURL, plc.startCfg.Env[exec.EnvBaseURL])
	require.NotEmpty(t, plc.startCfg.MCPs)
	prompt := testLaunchSurface(plc.startCfg, "baseInstructions") + testLaunchSurface(plc.startCfg, "developerInstructions") + testLaunchSurface(plc.startCfg, "freshPrompt") + testLaunchSurface(plc.startCfg, "prompt")
	require.Contains(t, prompt, "`agentteam exec --help`")
	require.Contains(t, prompt, "`agentteam exec <domain> --help`")
	require.Contains(t, prompt, "`agentteam exec <domain> <action> --help`")
	require.Contains(t, prompt, "`profile`")
	require.Contains(t, prompt, "`role`")
	require.NotContains(t, prompt, "`agentteam exec profile set-avatar`")
}

func TestExecAuthorizationLifecycleRevokesRuntimeAuthorizationAndCredential(t *testing.T) {
	t.Setenv("AGENTTEAM_CONFIG_DIR", t.TempDir())
	manager, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
	require.NoError(t, err)
	d := &Dispatcher{serverURL: "http://127.0.0.1:3456", execAuthorizations: exec.NewRuntimeAuthorizationStore(manager)}
	d.initServices()
	plc := execTestLaunchContext("agentteam-main")
	resolved := &execResolved{
		api: &apiclient.Client{AgentID: "server-agent", AuthToken: "token", RouteWorkspaceID: "workspace-1"},
	}
	cfg, err := d.activateExec(context.Background(), plc, "session-1", resolved)
	require.NoError(t, err)
	require.Equal(t, "http://127.0.0.1:3456/api/internal/exec/servers/agentteam-main/agents/agent-1", cfg.BaseURL)
	require.FileExists(t, cfg.TokenFile)

	drained := d.revokeExecAuthorizationConfig(cfg)
	select {
	case <-drained:
	case <-time.After(time.Second):
		t.Fatal("revocation did not drain")
	}
	require.NoFileExists(t, cfg.TokenFile)
	require.NotContains(t, d.authorizations, cfg.AuthorizationID)
}

func TestExecAuthorizationBaseURLPreservesEscapedPathIDs(t *testing.T) {
	t.Setenv("AGENTTEAM_CONFIG_DIR", t.TempDir())
	manager, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
	require.NoError(t, err)
	d := &Dispatcher{serverURL: "http://127.0.0.1:3456", execAuthorizations: exec.NewRuntimeAuthorizationStore(manager)}
	d.initServices()
	plc := execTestLaunchContext("ch space")
	plc.sess.agentID = "agent id"

	cfg, err := d.activateExec(context.Background(), plc, "session-1", &execResolved{
		api: &apiclient.Client{AgentID: "server-agent"},
	})
	require.NoError(t, err)
	t.Cleanup(func() { <-d.revokeExecAuthorizationConfig(cfg) })
	require.Equal(
		t,
		"http://127.0.0.1:3456/api/internal/exec/servers/ch%20space/agents/agent%20id",
		cfg.BaseURL,
	)
}

func TestExecAuthorizationRejectsIDsThatCannotReachGinSegmentRoutes(t *testing.T) {
	manager, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
	require.NoError(t, err)
	d := &Dispatcher{serverURL: "http://127.0.0.1:3456", execAuthorizations: exec.NewRuntimeAuthorizationStore(manager)}
	d.initServices()
	plc := execTestLaunchContext("ch/space")
	_, err = d.activateExec(context.Background(), plc, "session-1", &execResolved{
		api: &apiclient.Client{AgentID: "server-agent"},
	})
	require.ErrorContains(t, err, "URL path segments")
}

func TestExecAuthorizationRevocationDrainsAcquiredRequest(t *testing.T) {
	t.Setenv("AGENTTEAM_CONFIG_DIR", t.TempDir())
	manager, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
	require.NoError(t, err)
	d := &Dispatcher{serverURL: "http://127.0.0.1:3456", execAuthorizations: exec.NewRuntimeAuthorizationStore(manager)}
	d.initServices()
	plc := execTestLaunchContext("agentteam-main")
	cfg, err := d.activateExec(context.Background(), plc, "session-1", &execResolved{
		api: &apiclient.Client{AgentID: "server-agent"},
	})
	require.NoError(t, err)
	drained := d.revokeExecProcessAuthorization(execAuthorizationStub{id: cfg.AuthorizationID})
	select {
	case <-drained:
	case <-time.After(time.Second):
		t.Fatal("revocation did not drain")
	}
}

func TestExecRuntimeEnvInjectsCredentialFileAndClearsRawToken(t *testing.T) {
	base := map[string]string{
		"KEEP": "1", "PATH": "/bin",
		exec.EnvBaseURL:             "http://attacker.invalid",
		exec.EnvTokenFile:           "/tmp/attacker",
		"AGENTTEAM_EXEC_TOKEN":      "attacker",
		"agentteam_exec_base_url":   "http://mixed-case-attacker.invalid",
		"agentteam_exec_token_file": "/tmp/mixed-case-attacker",
		"agentteam_exec_token":      "mixed-case-attacker",
	}
	cfg := &drivercontract.ExecConfig{
		BaseURL:         "http://127.0.0.1:43123/api/internal/exec/servers/server/agents/agent",
		AuthorizationID: "authorization", TokenFile: "/private/token",
	}
	got := withExecRuntimeEnv(base, cfg)
	require.Equal(t, "1", got["KEEP"])
	require.Equal(t, "/bin", got["PATH"])
	require.Equal(t, cfg.BaseURL, got[exec.EnvBaseURL])
	require.Equal(t, cfg.TokenFile, got[exec.EnvTokenFile])
	require.NotContains(t, got, "AGENTTEAM_EXEC_TOKEN")
	require.NotContains(t, got, "AGENTTEAM_EXEC_LEASE_ID")
	for key := range got {
		require.False(t, strings.EqualFold(key, "AGENTTEAM_EXEC_TOKEN") && key != "AGENTTEAM_EXEC_TOKEN")
		require.False(t, strings.EqualFold(key, exec.EnvBaseURL) && key != exec.EnvBaseURL)
		require.False(t, strings.EqualFold(key, exec.EnvTokenFile) && key != exec.EnvTokenFile)
	}

	cleared := withExecRuntimeEnv(base, nil)
	require.Equal(t, "1", cleared["KEEP"])
	require.NotContains(t, cleared, exec.EnvBaseURL)
	require.NotContains(t, cleared, exec.EnvTokenFile)
	require.NotContains(t, cleared, "AGENTTEAM_EXEC_TOKEN")
}

func TestExecAuthorizationRotationPublishesNewCredentialWithoutChangingAuthorization(t *testing.T) {
	manager, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
	require.NoError(t, err)
	d := &Dispatcher{serverURL: "http://127.0.0.1:3456", execAuthorizations: exec.NewRuntimeAuthorizationStore(manager)}
	d.initServices()
	credential := newFakeExecCredential()
	d.credentialFactory = func(value string) (execCredential, error) {
		credential.recordInitial(value)
		return credential, nil
	}
	clock := &rotationTestClock{}
	clock.Set(time.Now())
	d.now = clock.Now
	wake := make(chan struct{}, 1)
	d.wait = func(stop <-chan struct{}, _ time.Duration) bool {
		select {
		case <-wake:
			return true
		case <-stop:
			return false
		}
	}
	credential.afterReplace = func() { clock.Set(time.Now()) }

	cfg, err := d.activateExec(context.Background(), execTestLaunchContext("agentteam-main"), "session-1", &execResolved{
		api: &apiclient.Client{AgentID: "server-agent"},
	})
	require.NoError(t, err)
	initial := credential.latest()
	clock.Set(time.Now().Add(execGenerationTTL + time.Hour))
	wake <- struct{}{}
	require.Eventually(t, func() bool { return credential.replaceCount() == 1 }, time.Second, time.Millisecond)
	rotated := credential.latest()
	require.NotEqual(t, initial, rotated)
	rotatedInfo, err := manager.ValidateExecToken("agent-1", "agentteam-main", rotated)
	require.NoError(t, err)
	require.NotEmpty(t, rotatedInfo.JTI)
	require.Equal(t, cfg.AuthorizationID, d.authorizations[cfg.AuthorizationID].id)

	<-d.revokeExecAuthorizationConfig(cfg)
	require.True(t, credential.isClosed())
}

func TestExecAuthorizationRotationPublishFailureKeepsPublishedTokenAndRetries(t *testing.T) {
	manager, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
	require.NoError(t, err)
	d := &Dispatcher{serverURL: "http://127.0.0.1:3456", execAuthorizations: exec.NewRuntimeAuthorizationStore(manager)}
	d.initServices()
	credential := newFakeExecCredential()
	credential.failNextReplace = true
	d.credentialFactory = func(value string) (execCredential, error) {
		credential.recordInitial(value)
		return credential, nil
	}
	clock := &rotationTestClock{}
	clock.Set(time.Now())
	d.now = clock.Now
	wake := make(chan struct{}, 2)
	d.wait = func(stop <-chan struct{}, _ time.Duration) bool {
		select {
		case <-wake:
			return true
		case <-stop:
			return false
		}
	}
	credential.afterReplace = func() { clock.Set(time.Now()) }

	cfg, err := d.activateExec(context.Background(), execTestLaunchContext("agentteam-main"), "session-1", &execResolved{
		api: &apiclient.Client{AgentID: "server-agent"},
	})
	require.NoError(t, err)
	initial := credential.latest()
	clock.Set(time.Now().Add(execGenerationTTL))
	wake <- struct{}{}
	require.Eventually(t, func() bool { return credential.replaceAttempts() == 1 }, time.Second, time.Millisecond)
	require.Equal(t, initial, credential.latest(), "failed publication must leave the previous credential intact")
	_, err = manager.ValidateExecToken("agent-1", "agentteam-main", initial)
	require.NoError(t, err, "the previously published generation must remain authorized")

	clock.Set(time.Now().Add(execGenerationTTL + time.Hour))
	wake <- struct{}{}
	require.Eventually(t, func() bool { return credential.replaceAttempts() == 2 }, time.Second, time.Millisecond)
	require.NotEqual(t, initial, credential.latest())

	<-d.revokeExecAuthorizationConfig(cfg)
}

func TestRevokeAllExecAuthorizationsJoinsRotatorsAndClosesCredentials(t *testing.T) {
	manager, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
	require.NoError(t, err)
	d := &Dispatcher{serverURL: "http://127.0.0.1:3456", execAuthorizations: exec.NewRuntimeAuthorizationStore(manager)}
	d.initServices()
	var credentialsMu sync.Mutex
	var credentials []*fakeExecCredential
	d.credentialFactory = func(value string) (execCredential, error) {
		credential := newFakeExecCredential()
		credential.recordInitial(value)
		credentialsMu.Lock()
		credentials = append(credentials, credential)
		credentialsMu.Unlock()
		return credential, nil
	}

	for i := 0; i < 2; i++ {
		cfg, activateErr := d.activateExec(context.Background(), execTestLaunchContext("agentteam-main"), "session-"+string(rune('1'+i)), &execResolved{
			api: &apiclient.Client{AgentID: "server-agent"},
		})
		require.NoError(t, activateErr)
		require.NotEmpty(t, cfg.AuthorizationID)
	}

	d.revokeAllExecAuthorizations(context.Background())
	require.Empty(t, d.authorizations)
	credentialsMu.Lock()
	defer credentialsMu.Unlock()
	require.Len(t, credentials, 2)
	for _, credential := range credentials {
		require.True(t, credential.isClosed())
	}
}

func TestDispatcherShutdownRevokesExecAuthorizationCreatedBeforeProcessPublication(t *testing.T) {
	for _, test := range []struct {
		name string
		stop func(*Dispatcher)
	}{
		{name: "close", stop: func(d *Dispatcher) { d.Close() }},
		{name: "bounded shutdown", stop: func(d *Dispatcher) {
			ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
			defer cancel()
			d.StopForShutdown(ctx)
		}},
	} {
		t.Run(test.name, func(t *testing.T) {
			t.Setenv("AGENTTEAM_CONFIG_DIR", t.TempDir())
			_ = initTokenForTest(t)
			d := mustNewDispatcher("http://127.0.0.1:3456")
			cfg, err := d.activateExec(
				context.Background(),
				execTestLaunchContext("agentteam-main"),
				"session-1",
				&execResolved{api: &apiclient.Client{AgentID: "server-agent"}},
			)
			require.NoError(t, err)
			require.FileExists(t, cfg.TokenFile)

			test.stop(d)

			require.NoFileExists(t, cfg.TokenFile)
			require.Empty(t, d.authorizations)
		})
	}
}

func TestExecAdmissionFenceRejectsPublicationThatWasCreatingCredential(t *testing.T) {
	manager, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
	require.NoError(t, err)
	d := &Dispatcher{serverURL: "http://127.0.0.1:3456", execAuthorizations: exec.NewRuntimeAuthorizationStore(manager)}
	d.initServices()
	credential := newFakeExecCredential()
	credential.closeFailures = 2
	d.cleanupRetryWait = func(time.Duration) {}
	factoryEntered := make(chan string, 1)
	allowFactoryReturn := make(chan struct{})
	d.credentialFactory = func(value string) (execCredential, error) {
		credential.recordInitial(value)
		factoryEntered <- value
		<-allowFactoryReturn
		return credential, nil
	}

	errCh := make(chan error, 1)
	go func() {
		_, activateErr := d.activateExec(
			context.Background(), execTestLaunchContext("agentteam-main"), "session-1",
			&execResolved{api: &apiclient.Client{AgentID: "server-agent"}},
		)
		errCh <- activateErr
	}()
	issued := <-factoryEntered
	d.closeExecAuthorizationAdmission()
	close(allowFactoryReturn)
	require.ErrorContains(t, <-errCh, "shutting down")
	require.True(t, credential.isClosed())
	require.Equal(t, 3, credential.closeAttempts())
	require.Empty(t, d.authorizations)
	_, err = manager.ValidateExecToken("agent-1", "agentteam-main", issued)
	require.Error(t, err)
}

func TestExecRevokeDoesNotWaitForBlockedCredentialPublication(t *testing.T) {
	manager, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
	require.NoError(t, err)
	d := &Dispatcher{serverURL: "http://127.0.0.1:3456", execAuthorizations: exec.NewRuntimeAuthorizationStore(manager)}
	d.initServices()
	credential := newFakeExecCredential()
	replaceEntered := make(chan struct{}, 1)
	allowReplace := make(chan struct{})
	credential.replaceEntered = replaceEntered
	credential.replaceBlock = allowReplace
	d.credentialFactory = func(value string) (execCredential, error) {
		credential.recordInitial(value)
		return credential, nil
	}
	clock := &rotationTestClock{}
	clock.Set(time.Now())
	d.now = clock.Now
	wake := make(chan struct{}, 1)
	d.wait = func(stop <-chan struct{}, _ time.Duration) bool {
		select {
		case <-wake:
			return true
		case <-stop:
			return false
		}
	}

	cfg, err := d.activateExec(
		context.Background(), execTestLaunchContext("agentteam-main"), "session-1",
		&execResolved{api: &apiclient.Client{AgentID: "server-agent"}},
	)
	require.NoError(t, err)
	clock.Set(time.Now().Add(execGenerationTTL + time.Hour))
	wake <- struct{}{}
	select {
	case <-replaceEntered:
	case <-time.After(time.Second):
		t.Fatal("rotation did not enter credential publication")
	}

	revoked := d.revokeExecAuthorizationConfig(cfg)
	select {
	case <-revoked:
		t.Fatal("credential cleanup completed while publication was still blocked")
	default:
	}
	// Revoke returned to its caller, so process teardown is free to call Stop.
	close(allowReplace)
	select {
	case <-revoked:
	case <-time.After(time.Second):
		t.Fatal("credential cleanup did not finish after publication unblocked")
	}
	require.True(t, credential.isClosed())
}

func TestExecCredentialCleanupDebtRetriesAndIsSharedByRepeatedRevoke(t *testing.T) {
	manager, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
	require.NoError(t, err)
	d := &Dispatcher{serverURL: "http://127.0.0.1:3456", execAuthorizations: exec.NewRuntimeAuthorizationStore(manager)}
	d.initServices()
	credential := newFakeExecCredential()
	credential.closeFailures = 2
	d.credentialFactory = func(value string) (execCredential, error) {
		credential.recordInitial(value)
		return credential, nil
	}
	retryEntered := make(chan struct{}, 2)
	allowRetry := make(chan struct{}, 2)
	d.cleanupRetryWait = func(time.Duration) {
		retryEntered <- struct{}{}
		<-allowRetry
	}

	cfg, err := d.activateExec(
		context.Background(), execTestLaunchContext("agentteam-main"), "session-1",
		&execResolved{api: &apiclient.Client{AgentID: "server-agent"}},
	)
	require.NoError(t, err)
	initial := credential.latest()

	first := d.revokeExecAuthorizationConfig(cfg)
	select {
	case <-retryEntered:
	case <-time.After(time.Second):
		t.Fatal("credential cleanup did not enter its first retry")
	}
	_, err = manager.ValidateExecToken("agent-1", "agentteam-main", initial)
	require.Error(t, err, "authorization must be revoked before file cleanup succeeds")
	require.Contains(t, d.authorizations, cfg.AuthorizationID, "cleanup debt must retain the discoverable lease")
	select {
	case <-first:
		t.Fatal("first revoke drained before credential cleanup succeeded")
	default:
	}

	second := d.revokeExecAuthorizationConfig(cfg)
	select {
	case <-second:
		t.Fatal("repeated revoke did not share the pending cleanup boundary")
	default:
	}
	require.Equal(t, 1, credential.closeAttempts())

	allowRetry <- struct{}{}
	select {
	case <-retryEntered:
	case <-time.After(time.Second):
		t.Fatal("credential cleanup did not enter its second retry")
	}
	require.Equal(t, 2, credential.closeAttempts())
	require.Contains(t, d.authorizations, cfg.AuthorizationID)
	allowRetry <- struct{}{}
	for index, drained := range []<-chan struct{}{first, second} {
		select {
		case <-drained:
		case <-time.After(time.Second):
			t.Fatalf("revoke %d did not drain after credential cleanup recovered", index+1)
		}
	}
	require.Equal(t, 3, credential.closeAttempts())
	require.True(t, credential.isClosed())
	require.NotContains(t, d.authorizations, cfg.AuthorizationID)
}

func TestExecCleanupOwnerRetainsAuthorizationUntilRequestsDrain(t *testing.T) {
	manager, err := token.NewKeyManager(t.TempDir() + "/config.yaml")
	require.NoError(t, err)
	d := &Dispatcher{serverURL: "http://127.0.0.1:3456", execAuthorizations: exec.NewRuntimeAuthorizationStore(manager)}
	d.initServices()
	credential := newFakeExecCredential()
	d.credentialFactory = func(value string) (execCredential, error) {
		credential.recordInitial(value)
		return credential, nil
	}
	authorizationRequestsDone := make(chan struct{})
	d.authorizationRevoke = func(authorizationID string) <-chan struct{} {
		return joinExecDrains(d.execAuthorizations.Revoke(authorizationID), authorizationRequestsDone)
	}

	cfg, err := d.activateExec(
		context.Background(), execTestLaunchContext("agentteam-main"), "session-1",
		&execResolved{api: &apiclient.Client{AgentID: "server-agent"}},
	)
	require.NoError(t, err)
	drained := d.revokeExecAuthorizationConfig(cfg)
	require.Eventually(t, func() bool { return credential.isClosed() }, time.Second, time.Millisecond)
	require.Contains(t, d.authorizations, cfg.AuthorizationID)
	select {
	case <-drained:
		t.Fatal("cleanup completed before the acquired authorization requests drained")
	default:
	}

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	d.revokeAllExecAuthorizations(ctx)
	require.Contains(t, d.authorizations, cfg.AuthorizationID, "bounded shutdown must leave discoverable cleanup debt")

	strongDone := make(chan struct{})
	go func() {
		d.revokeAllExecAuthorizations(context.Background())
		close(strongDone)
	}()
	select {
	case <-strongDone:
		t.Fatal("strong cleanup crossed the pending authorization-request boundary")
	default:
	}
	close(authorizationRequestsDone)
	for name, done := range map[string]<-chan struct{}{"revoke": drained, "strong sweep": strongDone} {
		select {
		case <-done:
		case <-time.After(time.Second):
			t.Fatalf("%s did not finish after authorization requests drained", name)
		}
	}
	require.NotContains(t, d.authorizations, cfg.AuthorizationID)
}

type execTestServerConnection struct{ mcpTestServerConnection }

func (c execTestServerConnection) APIClient(...string) *apiclient.Client {
	return &apiclient.Client{AgentID: "server-agent", AuthToken: "token", RouteWorkspaceID: "workspace-1"}
}

func execTestLaunchContext(channelID string) *processLaunchContext {
	return &processLaunchContext{
		sess:     &interactiveSession{agentID: "agent-1", workDir: "/tmp/agent-1"},
		runtime:  runtimeconfig.Runtime{Type: runtimeconfig.TypeCodex},
		binding:  conversationBinding{channelID: channelID, targetScopeType: "channel", targetID: "#agentteam", serverAgentID: "server-agent"},
		msg:      types.InboxMessage{MessageID: "msg-1", Target: "#agentteam"},
		agent:    agent.Config{},
		startCfg: drivercontract.LaunchConfig{AgentID: "agent-1", RuntimeType: runtimeconfig.TypeCodex, WorkDir: "/tmp/agent-1"},
	}
}

func newExecAuthorizationTestProcess(t *testing.T, authorizationID string) *agent.Process {
	t.Helper()
	process := &agent.Process{AgentID: "agent-1", SessionID: "session-1"}
	processValue := reflect.ValueOf(process).Elem()
	execField := processValue.FieldByName("exec")
	execValue := reflect.New(execField.Type().Elem())
	setUnexportedString(t, execValue.Elem().FieldByName("authorizationID"), authorizationID)
	setUnexportedValueForOwnershipTest(t, execField, execValue.Interface())
	return process
}

func readExecCredentialForTest(t *testing.T, path string) string {
	t.Helper()
	value, err := os.ReadFile(path)
	require.NoError(t, err)
	return strings.TrimSpace(string(value))
}

type execAuthorizationStub struct{ id string }

func (s execAuthorizationStub) ExecAuthorizationID() string { return s.id }

type rotationTestClock struct {
	mu  sync.Mutex
	now time.Time
}

func (c *rotationTestClock) Set(value time.Time) {
	c.mu.Lock()
	c.now = value
	c.mu.Unlock()
}

func (c *rotationTestClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

type fakeExecCredential struct {
	mu              sync.Mutex
	values          []string
	replaceCalls    int
	failNextReplace bool
	closed          bool
	afterReplace    func()
	replaceEntered  chan<- struct{}
	replaceBlock    <-chan struct{}
	closeFailures   int
	closeCalls      int
}

func newFakeExecCredential() *fakeExecCredential { return &fakeExecCredential{} }

func (c *fakeExecCredential) Path() string { return "/private/exec-token" }

func (c *fakeExecCredential) recordInitial(value string) {
	c.mu.Lock()
	c.values = append(c.values, value)
	c.mu.Unlock()
}

func (c *fakeExecCredential) Replace(value string) error {
	c.mu.Lock()
	c.replaceCalls++
	if c.failNextReplace {
		c.failNextReplace = false
		c.mu.Unlock()
		return os.ErrPermission
	}
	entered := c.replaceEntered
	block := c.replaceBlock
	after := c.afterReplace
	c.mu.Unlock()
	if entered != nil {
		entered <- struct{}{}
	}
	if block != nil {
		<-block
	}
	c.mu.Lock()
	c.values = append(c.values, value)
	c.mu.Unlock()
	if after != nil {
		after()
	}
	return nil
}

func (c *fakeExecCredential) Close() error {
	c.mu.Lock()
	c.closeCalls++
	if c.closeCalls <= c.closeFailures {
		c.mu.Unlock()
		return os.ErrPermission
	}
	c.closed = true
	c.mu.Unlock()
	return nil
}

func (c *fakeExecCredential) latest() string {
	c.mu.Lock()
	defer c.mu.Unlock()
	if len(c.values) == 0 {
		return ""
	}
	return c.values[len(c.values)-1]
}

func (c *fakeExecCredential) replaceCount() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return len(c.values) - 1
}

func (c *fakeExecCredential) replaceAttempts() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.replaceCalls
}

func (c *fakeExecCredential) isClosed() bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.closed
}

func (c *fakeExecCredential) closeAttempts() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.closeCalls
}
