package daemon

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/runtimecontrol"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
)

// TestMachineDirectoryBrowseSupportedRequiresConnectedAdvertisedEndpoint
// pins the three inputs to the live capability flag: a connected client, the
// daemon advertisement of machine.directory.list@1, and a matching endpoint
// registry version. Removing any of the three MUST flip the flag to false so
// the Web UI falls back to manual path input only when the daemon genuinely
// cannot browse — never on a transient read failure.
func TestMachineDirectoryBrowseSupportedRequiresConnectedAdvertisedEndpoint(t *testing.T) {
	now := time.Date(2026, 7, 1, 4, 0, 0, 0, time.UTC)
	manager := newTestElasticRequestLinkManager(t, &now, ElasticRequestLinkManagerConfig{})
	hub := &Hub{remoteEndpoints: defaultRemoteEndpointRegistry(), elasticRequestLinks: manager, clients: map[string]*clientState{}}

	require.False(t, hub.MachineDirectoryBrowseSupported("missing_machine"),
		"capability flag must be false when no client is connected")

	client := newElasticReadyClient()
	hub.clients[client.machineID] = client
	require.True(t, hub.MachineDirectoryBrowseSupported(client.machineID),
		"capability flag must be true when the daemon is connected and advertises the endpoint at the matching registry version")

	delete(client.supportedEndpointVersions, remoteEndpointMachineDirectoryList)
	require.False(t, hub.MachineDirectoryBrowseSupported(client.machineID),
		"capability flag must be false when the daemon omits machine.directory.list from its advertisement")

	client.supportedEndpointVersions[remoteEndpointMachineDirectoryList] = map[int]struct{}{2: {}}
	require.False(t, hub.MachineDirectoryBrowseSupported(client.machineID),
		"capability flag must be false when the daemon advertises a future major version")

	client.supportedEndpointVersions[remoteEndpointMachineDirectoryList] = map[int]struct{}{1: {}}
	client.endpointRegistryVersion = "daemon-endpoints:v0"
	require.False(t, hub.MachineDirectoryBrowseSupported(client.machineID),
		"capability flag must be false on endpoint registry version mismatch")
}

// TestMachineDirectoryListCapabilityMissReturnsTypedUnsupportedWithoutRequest
// pins the explicit preflight: a connected daemon that does not advertise
// machine.directory.list@1 MUST receive ErrMachineDirectoryUnsupported
// BEFORE any assignment is built or sent. The post-classifier's other
// buckets (busy, unavailable, invalid, unreadable) MUST NOT fire here —
// only a clean capability gap drives the Web UI's manual fallback.
func TestMachineDirectoryListCapabilityMissReturnsTypedUnsupportedWithoutRequest(t *testing.T) {
	now := time.Date(2026, 7, 1, 4, 10, 0, 0, time.UTC)
	metrics := newTestDaemonTransportMetrics()
	manager := newTestElasticRequestLinkManager(t, &now, ElasticRequestLinkManagerConfig{Metrics: metrics})
	hub := &Hub{remoteEndpoints: defaultRemoteEndpointRegistry(), elasticRequestLinks: manager, clients: map[string]*clientState{}, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}

	client := newElasticReadyClient()
	delete(client.supportedEndpointVersions, remoteEndpointMachineDirectoryList)
	hub.clients[client.machineID] = client

	_, err := hub.ListMachineDirectoriesPage(context.Background(), client.machineID, "/repo", agent.WorkspaceListOptions{Limit: 10})
	require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnsupported),
		"capability miss MUST surface ErrMachineDirectoryUnsupported; got %v", err)
	require.False(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnavailable),
		"capability miss MUST NOT collapse to ErrMachineDirectoryUnavailable (would hide fallback signal); got %v", err)
	require.False(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryBusy),
		"capability miss MUST NOT classify as busy; got %v", err)
	require.Equal(t, 0, countMetricsForEndpoint(metrics, remoteEndpointMachineDirectoryList),
		"capability miss MUST NOT record any metric (no assignment was attempted)")
}

// directoryReaderGoroutineResult is the bounded result one elastic-reader
// goroutine produces. Asserting on these fields in the main test goroutine
// (instead of inside the reader goroutine) avoids a runtime.Goexit from
// require.NoError crossing goroutine boundaries on timeout.
type directoryReaderGoroutineResult struct {
	endpointID string
	msgType    string
	payload    machineDirectoryListRemoteRequestPayload
	err        error
}

func startDirectoryElasticReader(t *testing.T, sender *captureElasticSender, manager *ElasticRequestLinkManager, response machineDirectoryListRemoteRequestResponse) <-chan directoryReaderGoroutineResult {
	t.Helper()
	resultCh := make(chan directoryReaderGoroutineResult, 1)
	go func() {
		message, ok := <-sender.messages
		if !ok {
			resultCh <- directoryReaderGoroutineResult{err: errors.New("sender closed before assignment arrived")}
			return
		}
		assignment, ok := message.(RemoteRequestAssignment[json.RawMessage])
		if !ok {
			resultCh <- directoryReaderGoroutineResult{err: errors.New("expected RemoteRequestAssignment message type")}
			return
		}
		var payload machineDirectoryListRemoteRequestPayload
		if err := json.Unmarshal(assignment.Payload, &payload); err != nil {
			resultCh <- directoryReaderGoroutineResult{endpointID: assignment.EndpointID, msgType: assignment.Type, err: err}
			return
		}
		manager.CompleteRemoteRequest(RemoteRequestResult[json.RawMessage]{
			Type:      sessionLinkRemoteRequestResultType,
			RequestID: assignment.RequestID,
			Success:   true,
			Outcome:   "ok",
			Payload:   mustMarshalMachineDirectoryResponse(t, response),
		})
		resultCh <- directoryReaderGoroutineResult{endpointID: assignment.EndpointID, msgType: assignment.Type, payload: payload}
	}()
	return resultCh
}

func startDirectoryElasticOutcomeReader(sender *captureElasticSender, manager *ElasticRequestLinkManager, outcome string) <-chan directoryReaderGoroutineResult {
	resultCh := make(chan directoryReaderGoroutineResult, 1)
	go func() {
		message, ok := <-sender.messages
		if !ok {
			resultCh <- directoryReaderGoroutineResult{err: errors.New("sender closed before assignment arrived")}
			return
		}
		assignment, ok := message.(RemoteRequestAssignment[json.RawMessage])
		if !ok {
			resultCh <- directoryReaderGoroutineResult{err: errors.New("expected RemoteRequestAssignment message type")}
			return
		}
		manager.CompleteRemoteRequest(RemoteRequestResult[json.RawMessage]{
			Type:      sessionLinkRemoteRequestResultType,
			RequestID: assignment.RequestID,
			Success:   false,
			Outcome:   outcome,
		})
		resultCh <- directoryReaderGoroutineResult{endpointID: assignment.EndpointID, msgType: assignment.Type}
	}()
	return resultCh
}

// directorySessionReaderGoroutineResult is the bounded result of a
// session-link reader goroutine. Same safety rationale as the elastic
// sibling: inline require.NoError inside a reader goroutine crosses
// goroutine boundaries on timeout; a result channel does not.
type directorySessionReaderGoroutineResult struct {
	endpointID string
	msgType    string
	err        error
}

// TestMachineDirectoryListForwardsRawPathAndMapsStableOutcomes pins two
// contracts: (1) the path field is forwarded byte-for-byte (no trim), so a
// trailing/leading whitespace path reaches the desktop verbatim; (2) the
// stable outcome strings machine_directory_invalid and
// machine_directory_unreadable are promoted to typed sentinels at the
// mapper boundary so the HTTP layer can answer 400/422 without leaking the
// offending path.
func TestMachineDirectoryListForwardsRawPathAndMapsStableOutcomes(t *testing.T) {
	t.Run("raw path forwarded byte-for-byte and page mapped", func(t *testing.T) {
		now := time.Date(2026, 7, 1, 4, 20, 0, 0, time.UTC)
		manager := newTestElasticRequestLinkManager(t, &now, ElasticRequestLinkManagerConfig{})
		hub := &Hub{remoteEndpoints: defaultRemoteEndpointRegistry(), elasticRequestLinks: manager, clients: map[string]*clientState{}, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
		client := newElasticReadyClient()
		hub.clients[client.machineID] = client
		descriptor, ok := defaultRemoteEndpointRegistry().Lookup(remoteEndpointMachineDirectoryList, 1)
		require.True(t, ok)
		open, _, err := manager.CreateLinkGrant(client, descriptor)
		require.NoError(t, err)
		sender := newCaptureElasticSender()
		_, err = manager.AcceptLink(client.workspaceID, open.LinkID, open.Token, sender)
		require.NoError(t, err)

		rawPath := " /repo "
		resultCh := startDirectoryElasticReader(t, sender, manager, machineDirectoryListRemoteRequestResponse{
			CurrentPath: "/resolved",
			ParentPath:  "/",
			Directories: []agent.MachineDirectory{{Name: "repo", Path: "/resolved/repo"}},
			Limit:       10,
			HasMore:     true,
			NextCursor:  "10",
		})

		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		defer cancel()
		page, err := hub.ListMachineDirectoriesPage(ctx, client.machineID, rawPath, agent.WorkspaceListOptions{Limit: 10})
		require.NoError(t, err)
		require.Equal(t, "/resolved", page.CurrentPath)
		require.Equal(t, "/", page.ParentPath)
		require.Equal(t, []agent.MachineDirectory{{Name: "repo", Path: "/resolved/repo"}}, page.Directories)
		require.True(t, page.HasMore)
		require.Equal(t, "10", page.NextCursor)

		select {
		case res := <-resultCh:
			require.NoError(t, res.err)
			require.Equal(t, remoteEndpointMachineDirectoryList, res.endpointID)
			require.Equal(t, rawPath, res.payload.Path, "raw path must be forwarded byte-for-byte")
		case <-time.After(2 * time.Second):
			t.Fatal("elastic reader goroutine did not produce a result within 2s")
		}
	})

	t.Run("invalid outcome maps to typed sentinel", func(t *testing.T) {
		now := time.Date(2026, 7, 1, 4, 30, 0, 0, time.UTC)
		manager := newTestElasticRequestLinkManager(t, &now, ElasticRequestLinkManagerConfig{})
		hub := &Hub{remoteEndpoints: defaultRemoteEndpointRegistry(), elasticRequestLinks: manager, clients: map[string]*clientState{}, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
		client := newElasticReadyClient()
		hub.clients[client.machineID] = client
		descriptor, ok := defaultRemoteEndpointRegistry().Lookup(remoteEndpointMachineDirectoryList, 1)
		require.True(t, ok)
		open, _, err := manager.CreateLinkGrant(client, descriptor)
		require.NoError(t, err)
		sender := newCaptureElasticSender()
		_, err = manager.AcceptLink(client.workspaceID, open.LinkID, open.Token, sender)
		require.NoError(t, err)

		resultCh := startDirectoryElasticOutcomeReader(sender, manager, runtimecontrol.MachineDirectoryInvalidOutcome)

		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		defer cancel()
		_, err = hub.ListMachineDirectoriesPage(ctx, client.machineID, "/whatever", agent.WorkspaceListOptions{Limit: 10})
		require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryInvalid),
			"invalid outcome MUST map to ErrMachineDirectoryInvalid; got %v", err)
		require.False(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnsupported),
			"invalid outcome MUST NOT classify as unsupported (would trigger Web fallback); got %v", err)

		select {
		case res := <-resultCh:
			require.NoError(t, res.err)
			require.Equal(t, remoteEndpointMachineDirectoryList, res.endpointID)
		case <-time.After(2 * time.Second):
			t.Fatal("elastic reader goroutine did not produce a result within 2s")
		}
	})

	t.Run("unreadable outcome maps to typed sentinel", func(t *testing.T) {
		now := time.Date(2026, 7, 1, 4, 40, 0, 0, time.UTC)
		manager := newTestElasticRequestLinkManager(t, &now, ElasticRequestLinkManagerConfig{})
		hub := &Hub{remoteEndpoints: defaultRemoteEndpointRegistry(), elasticRequestLinks: manager, clients: map[string]*clientState{}, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
		client := newElasticReadyClient()
		hub.clients[client.machineID] = client
		descriptor, ok := defaultRemoteEndpointRegistry().Lookup(remoteEndpointMachineDirectoryList, 1)
		require.True(t, ok)
		open, _, err := manager.CreateLinkGrant(client, descriptor)
		require.NoError(t, err)
		sender := newCaptureElasticSender()
		_, err = manager.AcceptLink(client.workspaceID, open.LinkID, open.Token, sender)
		require.NoError(t, err)

		resultCh := startDirectoryElasticOutcomeReader(sender, manager, runtimecontrol.MachineDirectoryUnreadableOutcome)

		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		defer cancel()
		_, err = hub.ListMachineDirectoriesPage(ctx, client.machineID, "/whatever", agent.WorkspaceListOptions{Limit: 10})
		require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnreadable),
			"unreadable outcome MUST map to ErrMachineDirectoryUnreadable; got %v", err)
		require.False(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnsupported),
			"unreadable outcome MUST NOT classify as unsupported; got %v", err)

		select {
		case res := <-resultCh:
			require.NoError(t, res.err)
			require.Equal(t, remoteEndpointMachineDirectoryList, res.endpointID)
		case <-time.After(2 * time.Second):
			t.Fatal("elastic reader goroutine did not produce a result within 2s")
		}
	})
}

// TestMachineDirectoryListClassifiesEveryRouteFailure walks the
// post-classifier table: each known route failure MUST map to its typed
// sentinel, and unexpected errors MUST collapse to
// ErrMachineDirectoryUnavailable with a path-free cause_category warning.
// No raw err text or path may reach the typed sentinel.
func TestMachineDirectoryListClassifiesEveryRouteFailure(t *testing.T) {
	t.Run("desktop_busy maps to busy via typed stable error", func(t *testing.T) {
		err, cause := classifyMachineDirectoryError(remoteRequestStableError(RemoteRequestStableErrorDesktopBusy))
		require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryBusy))
		require.Equal(t, "", cause)
	})

	t.Run("desktop_unavailable maps to unavailable via typed stable error", func(t *testing.T) {
		err, cause := classifyMachineDirectoryError(remoteRequestStableError(RemoteRequestStableErrorDesktopUnavailable))
		require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnavailable))
		require.Equal(t, "", cause)
	})

	t.Run("ErrMachineOffline maps to unavailable", func(t *testing.T) {
		err, cause := classifyMachineDirectoryError(runtimecontrol.ErrMachineOffline)
		require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnavailable))
		require.Equal(t, "", cause)
	})

	t.Run("deadline maps to unavailable with wait cause", func(t *testing.T) {
		err, cause := classifyMachineDirectoryError(context.DeadlineExceeded)
		require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnavailable))
		require.Equal(t, machineDirectoryCauseCategoryWait, cause)
	})

	t.Run("canceled maps to unavailable with wait cause", func(t *testing.T) {
		err, cause := classifyMachineDirectoryError(context.Canceled)
		require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnavailable))
		require.Equal(t, machineDirectoryCauseCategoryWait, cause)
	})

	t.Run("invalid passes through", func(t *testing.T) {
		err, cause := classifyMachineDirectoryError(runtimecontrol.ErrMachineDirectoryInvalid)
		require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryInvalid))
		require.Equal(t, "", cause)
	})

	t.Run("unreadable passes through", func(t *testing.T) {
		err, cause := classifyMachineDirectoryError(runtimecontrol.ErrMachineDirectoryUnreadable)
		require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnreadable))
		require.Equal(t, "", cause)
	})

	t.Run("unsupported passes through", func(t *testing.T) {
		err, cause := classifyMachineDirectoryError(runtimecontrol.ErrMachineDirectoryUnsupported)
		require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnsupported))
		require.Equal(t, "", cause)
	})

	t.Run("unknown error collapses to unavailable with path-free warning", func(t *testing.T) {
		// Path-leak tripwire: the raw error carries a path marker; the typed
		// sentinel returned to callers MUST NOT carry it. The classifier
		// never reads this text — it only checks typed sentinels and stable
		// error types — but the assertion documents the no-leak contract.
		err, cause := classifyMachineDirectoryError(errors.New("endpoint_error path=/tmp/SECRET_MARKER mode=leak"))
		require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnavailable))
		require.NotContains(t, err.Error(), "SECRET_MARKER",
			"typed sentinel MUST NOT carry raw path text")
		require.Equal(t, machineDirectoryCauseCategoryUnknown, cause,
			"unknown failure MUST yield cause_category=unknown so the warning fires")
	})
}

// TestMachineDirectoryListUsesTypedSessionDataPlaneWhenElasticUnavailable
// pins the elastic-failure fallback: when the connected daemon advertises
// the endpoint (capability preflight passes) but the elastic carrier is
// emergency-disabled, the typed RemoteRequestAssignment envelope MUST route
// over the session link data plane instead of returning a stable error.
// EmergencyDisabled forces DecideRoute through stableOrFallbackDecision →
// SessionLink, so the reader observes an assignment (not an open frame).
func TestMachineDirectoryListUsesTypedSessionDataPlaneWhenElasticUnavailable(t *testing.T) {
	serverConn, desktopConn := newDaemonProtocolTestPair(t)
	client := newWorkspaceListRemoteReadyClient(serverConn)
	now := time.Date(2026, 7, 1, 4, 50, 0, 0, time.UTC)
	manager := newTestElasticRequestLinkManager(t, &now, ElasticRequestLinkManagerConfig{EmergencyDisabled: true})
	hub := &Hub{
		remoteEndpoints:     defaultRemoteEndpointRegistry(),
		elasticRequestLinks: manager,
		clients:             map[string]*clientState{client.machineID: client},
		logger:              slog.New(slog.NewTextHandler(io.Discard, nil)),
	}

	resultCh := make(chan directorySessionReaderGoroutineResult, 1)
	go func() {
		var assignment RemoteRequestAssignment[json.RawMessage]
		if err := desktopConn.ReadJSON(&assignment); err != nil {
			resultCh <- directorySessionReaderGoroutineResult{err: err}
			return
		}
		err := handleSessionLinkRemoteRequestResultCommand(hub, context.Background(), client, mustMarshalRemoteWorkspaceTest(t, RemoteRequestResult[json.RawMessage]{
			Type:      sessionLinkRemoteRequestResultType,
			RequestID: assignment.RequestID,
			Success:   true,
			Outcome:   "ok",
			Payload: mustMarshalMachineDirectoryResponse(t, machineDirectoryListRemoteRequestResponse{
				CurrentPath: "/resolved",
				ParentPath:  "/",
				Directories: []agent.MachineDirectory{{Name: "repo", Path: "/resolved/repo"}},
				Limit:       10,
			}),
		}))
		resultCh <- directorySessionReaderGoroutineResult{endpointID: assignment.EndpointID, msgType: assignment.Type, err: err}
	}()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	page, err := hub.ListMachineDirectoriesPage(ctx, client.machineID, "/repo", agent.WorkspaceListOptions{Limit: 10})
	require.NoError(t, err)
	require.Equal(t, "/resolved", page.CurrentPath)
	require.Equal(t, []agent.MachineDirectory{{Name: "repo", Path: "/resolved/repo"}}, page.Directories)

	select {
	case res := <-resultCh:
		require.NoError(t, res.err, "session-link reader goroutine failed")
		require.Equal(t, remoteEndpointMachineDirectoryList, res.endpointID)
		require.Equal(t, sessionLinkRemoteRequestAssignType, res.msgType)
	case <-time.After(2 * time.Second):
		t.Fatal("session-link reader goroutine did not produce a result within 2s")
	}
}

// TestMachineDirectoryListDoesNotRetryAfterEndpointError pins the no-retry
// guarantee: SafeRetryFallback=false means a single endpoint_error outcome
// MUST produce exactly one assignment and one metrics record. A retry path
// would produce two assignments (elastic + legacy) and inflate dashboards.
// EmergencyDisabled forces SessionLink routing so the reader observes the
// single assignment deterministically.
func TestMachineDirectoryListDoesNotRetryAfterEndpointError(t *testing.T) {
	serverConn, desktopConn := newDaemonProtocolTestPair(t)
	client := newWorkspaceListRemoteReadyClient(serverConn)
	now := time.Date(2026, 7, 1, 5, 0, 0, 0, time.UTC)
	metrics := newTestDaemonTransportMetrics()
	manager := newTestElasticRequestLinkManager(t, &now, ElasticRequestLinkManagerConfig{Metrics: metrics, EmergencyDisabled: true})
	hub := &Hub{
		remoteEndpoints:     defaultRemoteEndpointRegistry(),
		elasticRequestLinks: manager,
		clients:             map[string]*clientState{client.machineID: client},
		logger:              slog.New(slog.NewTextHandler(io.Discard, nil)),
	}

	assignmentCount := 0
	var assignmentMu sync.Mutex
	resultCh := make(chan directorySessionReaderGoroutineResult, 1)
	go func() {
		var assignment RemoteRequestAssignment[json.RawMessage]
		if err := desktopConn.ReadJSON(&assignment); err != nil {
			resultCh <- directorySessionReaderGoroutineResult{err: err}
			return
		}
		assignmentMu.Lock()
		assignmentCount++
		assignmentMu.Unlock()
		err := handleSessionLinkRemoteRequestResultCommand(hub, context.Background(), client, mustMarshalRemoteWorkspaceTest(t, RemoteRequestResult[json.RawMessage]{
			Type:      sessionLinkRemoteRequestResultType,
			RequestID: assignment.RequestID,
			Success:   false,
			Outcome:   "endpoint_error",
			Error:     "endpoint_error",
		}))
		resultCh <- directorySessionReaderGoroutineResult{endpointID: assignment.EndpointID, msgType: assignment.Type, err: err}
	}()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	_, err := hub.ListMachineDirectoriesPage(ctx, client.machineID, "/repo", agent.WorkspaceListOptions{Limit: 10})
	require.Error(t, err)
	require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnavailable),
		"endpoint_error MUST post-classify to ErrMachineDirectoryUnavailable; got %v", err)

	select {
	case res := <-resultCh:
		require.NoError(t, res.err, "session-link reader goroutine failed")
		require.Equal(t, remoteEndpointMachineDirectoryList, res.endpointID)
	case <-time.After(2 * time.Second):
		t.Fatal("session-link reader goroutine did not produce a result within 2s")
	}

	assignmentMu.Lock()
	require.Equal(t, 1, assignmentCount, "exactly ONE assignment MUST be observed (no retry)")
	assignmentMu.Unlock()
	require.False(t, anyMetricWithFallback(metrics, "safe_retry"),
		"no safe_retry metric MUST be recorded (SafeRetryFallback=false)")
}

// TestMachineDirectoryTelemetryNeverContainsPath pins the path-free
// telemetry contract end-to-end. The raw path marker MUST NOT appear in any
// recorded metric label or in the Hub's warning attributes.
func TestMachineDirectoryTelemetryNeverContainsPath(t *testing.T) {
	const pathMarker = "SECRET_PATH_MARKER_xyz"
	now := time.Date(2026, 7, 1, 5, 10, 0, 0, time.UTC)
	metrics := newTestDaemonTransportMetrics()
	manager := newTestElasticRequestLinkManager(t, &now, ElasticRequestLinkManagerConfig{Metrics: metrics})

	var captured []capturedSlogRecord
	capture := newCapturingSlogHandler(&captured)
	hub := &Hub{
		remoteEndpoints:     defaultRemoteEndpointRegistry(),
		elasticRequestLinks: manager,
		clients:             map[string]*clientState{},
		logger:              slog.New(capture),
	}

	client := newElasticReadyClient()
	hub.clients[client.machineID] = client
	descriptor, ok := defaultRemoteEndpointRegistry().Lookup(remoteEndpointMachineDirectoryList, 1)
	require.True(t, ok)
	open, _, err := manager.CreateLinkGrant(client, descriptor)
	require.NoError(t, err)
	sender := newCaptureElasticSender()
	_, err = manager.AcceptLink(client.workspaceID, open.LinkID, open.Token, sender)
	require.NoError(t, err)

	readerDone := make(chan struct{})
	go func() {
		defer close(readerDone)
		message, ok := <-sender.messages
		if !ok {
			return
		}
		assignment, ok := message.(RemoteRequestAssignment[json.RawMessage])
		if !ok {
			return
		}
		manager.CompleteRemoteRequest(RemoteRequestResult[json.RawMessage]{
			Type:      sessionLinkRemoteRequestResultType,
			RequestID: assignment.RequestID,
			Success:   false,
			Outcome:   "endpoint_error",
		})
	}()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	_, err = hub.ListMachineDirectoriesPage(ctx, client.machineID, "/tmp/"+pathMarker, agent.WorkspaceListOptions{Limit: 10})
	require.Error(t, err)
	require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryUnavailable))

	select {
	case <-readerDone:
	case <-time.After(2 * time.Second):
		t.Fatal("elastic reader goroutine did not complete within 2s")
	}

	metrics.mu.Lock()
	for _, req := range metrics.requests {
		require.NotContains(t, req.endpoint, pathMarker, "endpoint label MUST NOT carry path")
		require.NotContains(t, req.linkMode, pathMarker, "linkMode label MUST NOT carry path")
		require.NotContains(t, req.outcome, pathMarker, "outcome label MUST NOT carry path")
		require.NotContains(t, req.fallback, pathMarker, "fallback label MUST NOT carry path")
	}
	for _, route := range metrics.routeDecisions {
		require.NotContains(t, route.endpoint, pathMarker, "route endpoint label MUST NOT carry path")
		require.NotContains(t, route.reason, pathMarker, "route reason label MUST NOT carry path")
	}
	metrics.mu.Unlock()

	require.NotEmpty(t, captured, "unknown failure MUST emit a path-free warning")
	last := captured[len(captured)-1]
	require.NotContains(t, last.message, pathMarker, "warning message MUST NOT carry path")
	require.NotContains(t, last.attrs, pathMarker, "warning attrs MUST NOT carry path")
	require.Equal(t, "machine directory browsing failed unexpectedly", last.message)
	// cause_category MUST be one of the fixed enum values.
	require.Contains(t, last.attrs, "cause_category=",
		"warning MUST attach a fixed cause_category")
	knownCategories := []string{
		machineDirectoryCauseCategoryBuildSpec, machineDirectoryCauseCategoryRoute,
		machineDirectoryCauseCategorySend, machineDirectoryCauseCategoryWait,
		machineDirectoryCauseCategoryDecode, machineDirectoryCauseCategoryProtocol,
		machineDirectoryCauseCategoryUnknown,
	}
	matched := false
	for _, candidate := range knownCategories {
		if strings.Contains(last.attrs, "cause_category="+candidate) {
			matched = true
			break
		}
	}
	require.True(t, matched, "cause_category value MUST be one of the fixed enum; got %q", last.attrs)
}

// TestMachineDirectoryByteBudgetGuardReturnsInvalid pins the request-byte
// guard: an oversized path MUST return ErrMachineDirectoryInvalid before
// the spec builder asks the registry to build an assignment. The 64 KiB
// descriptor limit is the cap.
func TestMachineDirectoryByteBudgetGuardReturnsInvalid(t *testing.T) {
	descriptor, ok := defaultRemoteEndpointRegistry().Lookup(remoteEndpointMachineDirectoryList, 1)
	require.True(t, ok)
	oversized := strings.Repeat("a", int(descriptor.Limits.RequestBytes+1))
	_, err := newMachineDirectoryListRemoteRequestSpec(context.Background(), descriptor, oversized, agent.WorkspaceListOptions{Limit: 10})
	require.True(t, errors.Is(err, runtimecontrol.ErrMachineDirectoryInvalid),
		"oversized payload MUST return ErrMachineDirectoryInvalid before assignment build; got %v", err)
}

// TestMachineDirectoryListOptionsDoesNotTrimCursor pins the local
// options-shaping helper: only limit is defaulted/capped, cursor is left
// byte-for-byte. The Task 4 HTTP layer is the only place that may apply a
// strict cursor parser; the Hub MUST forward raw caller input.
func TestMachineDirectoryListOptionsDoesNotTrimCursor(t *testing.T) {
	cases := []struct {
		name   string
		opts   agent.WorkspaceListOptions
		limit  int
		cursor string
	}{
		{name: "empty limit defaults to 50", opts: agent.WorkspaceListOptions{Cursor: "  10  "}, limit: agent.DefaultWorkspaceListLimit, cursor: "  10  "},
		{name: "negative limit defaults to 50", opts: agent.WorkspaceListOptions{Limit: -1}, limit: agent.DefaultWorkspaceListLimit, cursor: ""},
		{name: "over-cap limit caps to 200", opts: agent.WorkspaceListOptions{Limit: 1000}, limit: agent.MaxWorkspaceListLimit, cursor: ""},
		{name: "explicit limit preserved", opts: agent.WorkspaceListOptions{Limit: 25, Cursor: "abc"}, limit: 25, cursor: "abc"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := defaultAndCapMachineDirectoryListOptions(tc.opts)
			require.Equal(t, tc.limit, got.Limit)
			require.Equal(t, tc.cursor, got.Cursor, "cursor must be forwarded byte-for-byte")
		})
	}
}

// capturedSlogRecord is one slog record captured by newCapturingSlogHandler.
type capturedSlogRecord struct {
	level   slog.Level
	message string
	attrs   string
}

type capturingSlogHandler struct {
	inner    slog.Handler
	captured *[]capturedSlogRecord
	mu       sync.Mutex
}

func newCapturingSlogHandler(captured *[]capturedSlogRecord) *capturingSlogHandler {
	return &capturingSlogHandler{
		inner:    slog.NewTextHandler(io.Discard, nil),
		captured: captured,
	}
}

func (h *capturingSlogHandler) Enabled(_ context.Context, lvl slog.Level) bool {
	return h.inner.Enabled(context.Background(), lvl)
}

func (h *capturingSlogHandler) Handle(ctx context.Context, r slog.Record) error {
	var attrBuf bytes.Buffer
	r.Attrs(func(a slog.Attr) bool {
		attrBuf.WriteString(a.Key)
		attrBuf.WriteString("=")
		attrBuf.WriteString(a.Value.String())
		attrBuf.WriteString(" ")
		return true
	})
	h.mu.Lock()
	*h.captured = append(*h.captured, capturedSlogRecord{
		level:   r.Level,
		message: r.Message,
		attrs:   attrBuf.String(),
	})
	h.mu.Unlock()
	return h.inner.Handle(ctx, r.Clone())
}

func (h *capturingSlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	return &capturingSlogHandler{inner: h.inner.WithAttrs(attrs), captured: h.captured}
}

func (h *capturingSlogHandler) WithGroup(name string) slog.Handler {
	return &capturingSlogHandler{inner: h.inner.WithGroup(name), captured: h.captured}
}

func mustMarshalMachineDirectoryResponse(t *testing.T, response machineDirectoryListRemoteRequestResponse) json.RawMessage {
	t.Helper()
	payload, err := json.Marshal(response)
	require.NoError(t, err)
	return payload
}
