package daemon

import (
	"context"
	"encoding/json"
	"errors"
	"strings"
	"time"

	"github.com/google/uuid"

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

type remoteRequestRouteOptionsContextKey struct{}

func withRemoteRequestRouteOptions(ctx context.Context, opts RemoteRequestRouteOptions) context.Context {
	key := remoteRequestRouteOptionsContextKey{}
	return context.WithValue(ctx, key, opts)
}

func remoteRequestRouteOptionsFromContext(ctx context.Context) RemoteRequestRouteOptions {
	if ctx == nil {
		return RemoteRequestRouteOptions{}
	}
	opts, _ := ctx.Value(remoteRequestRouteOptionsContextKey{}).(RemoteRequestRouteOptions)
	return opts
}

// listWorkspace is the workspace.list Remote Endpoint entry point. LinkPolicy
// decides whether the request uses a Session Link Remote Request, an Elastic
// Request Link assignment, or the legacy Session Link command fallback.
func (hub *Hub) listWorkspace(ctx context.Context, client *clientState, agentID string, targetPath string, dirPath string, opts agent.WorkspaceListOptions) (agent.WorkspaceFilePage, error) {
	if client == nil {
		return agent.WorkspaceFilePage{}, errors.New("desktop_unavailable")
	}
	opts = normalizeWorkspaceListOptions(opts)
	descriptor, ok := hub.remoteEndpointDescriptor(remoteEndpointWorkspaceList, 1)
	if !ok || hub.elasticRequestLinks == nil {
		return client.listWorkspace(ctx, agentID, targetPath, dirPath, opts)
	}
	return hub.invokeWorkspaceListRemoteEndpoint(ctx, client, descriptor, agentID, targetPath, dirPath, opts)
}

func (hub *Hub) readWorkspaceFile(ctx context.Context, client *clientState, agentID string, targetPath string, path string) (agent.WorkspaceFileContent, error) {
	if client == nil {
		return agent.WorkspaceFileContent{}, errors.New("desktop_unavailable")
	}
	descriptor, ok := hub.remoteEndpointDescriptor(remoteEndpointWorkspaceRead, 1)
	if !ok || hub.elasticRequestLinks == nil {
		return client.readWorkspaceFile(ctx, agentID, targetPath, path)
	}
	return invokeRemoteEndpoint(RemoteEndpointInvocation[agent.WorkspaceFileContent]{
		Context:      ctx,
		Client:       client,
		Manager:      hub.elasticRequestLinks,
		Descriptor:   descriptor,
		RouteOptions: remoteRequestRouteOptionsFromContext(ctx),
		Fallback: func() (agent.WorkspaceFileContent, error) {
			return client.readWorkspaceFile(ctx, agentID, targetPath, path)
		},
		BuildSpec: func() (RemoteRequestAssignmentSpec, error) {
			return newWorkspaceReadRemoteRequestSpec(ctx, descriptor, agentID, targetPath, path)
		},
		MapResult: workspaceFileContentFromRemoteResult,
	})
}

func (hub *Hub) applySeedFiles(ctx context.Context, client *clientState, agentID string, seedFiles []agent.WorkspaceSeedFile) error {
	if client == nil {
		return errors.New("desktop_unavailable")
	}
	descriptor, ok := hub.remoteEndpointDescriptor(remoteEndpointWorkspaceApplySeedFiles, 1)
	if !ok || hub.elasticRequestLinks == nil {
		return client.applySeedFiles(ctx, agentID, seedFiles)
	}
	_, err := invokeRemoteEndpoint(RemoteEndpointInvocation[struct{}]{
		Context:      ctx,
		Client:       client,
		Manager:      hub.elasticRequestLinks,
		Descriptor:   descriptor,
		RouteOptions: writeWorkspaceRouteOptions(ctx),
		Fallback: func() (struct{}, error) {
			return struct{}{}, client.applySeedFiles(ctx, agentID, seedFiles)
		},
		BuildSpec: func() (RemoteRequestAssignmentSpec, error) {
			return newWorkspaceApplySeedFilesRemoteRequestSpec(ctx, descriptor, agentID, seedFiles)
		},
		MapResult: workspaceApplySeedFilesFromRemoteResult,
	})
	return err
}

func (hub *Hub) deleteWorkspacePath(ctx context.Context, client *clientState, agentID string, path string) error {
	if client == nil {
		return errors.New("desktop_unavailable")
	}
	descriptor, ok := hub.remoteEndpointDescriptor(remoteEndpointWorkspaceDelete, 1)
	if !ok || hub.elasticRequestLinks == nil {
		return client.deleteWorkspacePath(ctx, agentID, path)
	}
	_, err := invokeRemoteEndpoint(RemoteEndpointInvocation[struct{}]{
		Context:      ctx,
		Client:       client,
		Manager:      hub.elasticRequestLinks,
		Descriptor:   descriptor,
		RouteOptions: remoteRequestRouteOptionsFromContext(ctx),
		Fallback: func() (struct{}, error) {
			return struct{}{}, client.deleteWorkspacePath(ctx, agentID, path)
		},
		BuildSpec: func() (RemoteRequestAssignmentSpec, error) {
			return newWorkspaceDeleteRemoteRequestSpec(ctx, descriptor, agentID, path)
		},
		MapResult: workspaceDeleteFromRemoteResult,
	})
	return err
}

func (hub *Hub) invokeWorkspaceListRemoteEndpoint(ctx context.Context, client *clientState, descriptor RemoteEndpointDescriptor, agentID string, targetPath string, dirPath string, opts agent.WorkspaceListOptions) (agent.WorkspaceFilePage, error) {
	return invokeRemoteEndpoint(RemoteEndpointInvocation[agent.WorkspaceFilePage]{
		Context:      ctx,
		Client:       client,
		Manager:      hub.elasticRequestLinks,
		Descriptor:   descriptor,
		RouteOptions: workspaceListRouteOptions(ctx, opts),
		Fallback: func() (agent.WorkspaceFilePage, error) {
			return client.listWorkspace(ctx, agentID, targetPath, dirPath, opts)
		},
		BuildSpec: func() (RemoteRequestAssignmentSpec, error) {
			return newWorkspaceListRemoteRequestSpec(ctx, descriptor, agentID, targetPath, dirPath, opts)
		},
		MapResult: workspaceListPageFromRemoteResult,
	})
}

// RemoteEndpointInvocation collects the stable inputs for one Desktop-bound
// Remote Endpoint call. Keeping route policy, fallback, assignment construction,
// and result mapping together gives the warm-pool work a single seam without
// changing today's LinkPolicy or fallback behavior.
type RemoteEndpointInvocation[T any] struct {
	Context      context.Context
	Client       *clientState
	Manager      *ElasticRequestLinkManager
	Descriptor   RemoteEndpointDescriptor
	RouteOptions RemoteRequestRouteOptions
	// RequestContext is captured now so later warm-pool batches can consume an
	// explicit origin without inferring user activity from endpoint names.
	RequestContext runtimecontrol.RemoteRequestContext
	Fallback       func() (T, error)
	BuildSpec      func() (RemoteRequestAssignmentSpec, error)
	MapResult      func(RemoteRequestResult[json.RawMessage]) (T, error)
}

func (invocation RemoteEndpointInvocation[T]) withDefaults() RemoteEndpointInvocation[T] {
	if invocation.Context == nil {
		invocation.Context = context.Background()
	}
	if invocation.RequestContext.Origin == "" && invocation.RequestContext.Cause == "" {
		invocation.RequestContext = runtimecontrol.RemoteRequestContextFromContext(invocation.Context)
	} else {
		invocation.RequestContext = runtimecontrol.RemoteRequestContextFromContext(
			runtimecontrol.WithRemoteRequestContext(invocation.Context, invocation.RequestContext),
		)
	}
	return invocation
}

func invokeRemoteEndpoint[T any](invocation RemoteEndpointInvocation[T]) (T, error) {
	invocation = invocation.withDefaults()
	ctx := invocation.Context
	warmKey, warmProfileName, reconcileWarm := invocation.Manager.PrepareWarmPoolActivity(ctx, invocation.Client, invocation.Descriptor, invocation.RequestContext)
	decision := invocation.Manager.DecideRoute(invocation.Client, invocation.Descriptor, invocation.RouteOptions)
	switch decision.Mode {
	case RemoteRequestRouteSessionLink:
		// Session Link Remote Request uses the same typed assignment envelope as
		// Elastic Request Link, preserving endpoint/version/deadline/limit semantics.
		startedAt := time.Now()
		value, err := invokeViaSessionRemoteRequest(ctx, invocation.Client, invocation.BuildSpec, invocation.MapResult, func() {
			if reconcileWarm {
				invocation.Manager.ReconcileWarmPool(invocation.Client, warmKey, warmProfileName)
			}
		})
		if err == nil || !invocation.Descriptor.SafeRetryFallback || errors.Is(err, runtimecontrol.ErrReviewTargetUnsupported) {
			// ErrReviewTargetUnsupported is a non-fallback sentinel (codex6 /
			// reviewer3 unified classification): the desktop has signaled a
			// product limitation (control-char paths in the worktree) that the
			// legacy command path CANNOT fix — it would just re-surface the
			// same 422 (or worse, downgrade it to a 501 via the legacy
			// ErrorCode mapper). Falling back would also overwrite the typed
			// sentinel returned here, breaking errors.Is at the HTTP layer.
			// Metrics MUST record this as fallback=none so dashboards do not
			// show inflated safe_retry counts for unsupported targets.
			recordRemoteEndpointRequest(invocation.Manager, invocation.Descriptor, "session_link", err, "none", time.Since(startedAt))
			return value, err
		}
		// Only retry through the legacy command when the endpoint descriptor marks
		// the logical operation safe to repeat AND the error is not the typed
		// non-fallback sentinel (handled above).
		fallbackStartedAt := time.Now()
		fallbackValue, fallbackErr := invocation.Fallback()
		recordRemoteEndpointRequest(invocation.Manager, invocation.Descriptor, "session_link", err, "safe_retry", time.Since(startedAt))
		recordRemoteEndpointRequest(invocation.Manager, invocation.Descriptor, "legacy_session_link", fallbackErr, "safe_retry", time.Since(fallbackStartedAt))
		return fallbackValue, fallbackErr
	case RemoteRequestRouteElasticRequestLink:
		return invokeViaElasticRequestLinkOrFallback(ctx, invocation.Manager, invocation.Descriptor, decision.LinkID, invocation.Fallback, invocation.BuildSpec, invocation.MapResult, func() {
			if reconcileWarm {
				invocation.Manager.ReconcileWarmPool(invocation.Client, warmKey, warmProfileName)
			}
		})
	case RemoteRequestRouteOpenElasticRequestLink:
		if decision.OpenCommand == nil {
			return invocation.elasticFailureFallback(RemoteRequestStableErrorDesktopUnavailable, "open_failed")
		}
		if err := invocation.Client.sessionLinkSender().Send(*decision.OpenCommand); err != nil {
			return invocation.elasticFailureFallback(RemoteRequestStableErrorDesktopUnavailable, "send_open_failed")
		}
		if reconcileWarm {
			invocation.Manager.ReconcileWarmPool(invocation.Client, warmKey, warmProfileName)
		}
		// DialTimeout is only the carrier-establishment budget; the assignment below
		// still carries its own per-request deadline and timeout budget.
		dialCtx, cancel := context.WithTimeout(ctx, invocation.Descriptor.DialTimeout)
		defer cancel()
		if err := invocation.Manager.WaitForLink(dialCtx, decision.OpenCommand.LinkID); err != nil {
			invocation.Manager.CleanupLinkGrant(decision.OpenCommand.LinkID, "connect_timeout")
			return invocation.elasticFailureFallback(RemoteRequestStableErrorDesktopUnavailable, "connect_timeout")
		}
		return invokeViaElasticRequestLinkOrFallback(ctx, invocation.Manager, invocation.Descriptor, decision.OpenCommand.LinkID, invocation.Fallback, invocation.BuildSpec, invocation.MapResult, nil)
	case RemoteRequestRouteLegacySessionLinkFallback:
		startedAt := time.Now()
		if reconcileWarm {
			invocation.Manager.ReconcileWarmPool(invocation.Client, warmKey, warmProfileName)
		}
		value, err := invocation.Fallback()
		recordRemoteEndpointRequest(invocation.Manager, invocation.Descriptor, "legacy_session_link", err, "route_fallback", time.Since(startedAt))
		return value, err
	case RemoteRequestRouteStableError:
		if reconcileWarm {
			defer invocation.Manager.ReconcileWarmPool(invocation.Client, warmKey, warmProfileName)
		}
		var zero T
		err := remoteRequestStableError(decision.StableError)
		recordRemoteEndpointRequest(invocation.Manager, invocation.Descriptor, "none", err, "none", 0)
		return zero, err
	default:
		if reconcileWarm {
			defer invocation.Manager.ReconcileWarmPool(invocation.Client, warmKey, warmProfileName)
		}
		return invocation.elasticFailureFallback(RemoteRequestStableErrorDesktopUnavailable, "route_fallback")
	}
}

func (hub *Hub) remoteEndpointDescriptor(endpointID string, version int) (RemoteEndpointDescriptor, bool) {
	registry := hub.remoteEndpoints
	if registry == nil {
		registry = defaultRemoteEndpointRegistry()
	}
	return registry.Lookup(endpointID, version)
}

func workspaceListRouteOptions(ctx context.Context, opts agent.WorkspaceListOptions) RemoteRequestRouteOptions {
	routeOpts := remoteRequestRouteOptionsFromContext(ctx)
	if opts.Limit > agent.DefaultWorkspaceListLimit {
		routeOpts.SessionLinkUnderPressure = true
	}
	return routeOpts
}

func writeWorkspaceRouteOptions(ctx context.Context) RemoteRequestRouteOptions {
	routeOpts := remoteRequestRouteOptionsFromContext(ctx)
	routeOpts.ForceElastic = true
	return routeOpts
}

func invokeViaElasticRequestLinkOrFallback[T any](ctx context.Context, manager *ElasticRequestLinkManager, descriptor RemoteEndpointDescriptor, linkID string, fallback func() (T, error), specBuilder func() (RemoteRequestAssignmentSpec, error), resultMapper func(RemoteRequestResult[json.RawMessage]) (T, error), afterAssign func()) (T, error) {
	startedAt := time.Now()
	value, err := invokeViaElasticRequestLink(ctx, manager, linkID, specBuilder, resultMapper, afterAssign)
	if err == nil || !descriptor.SafeRetryFallback || errors.Is(err, runtimecontrol.ErrReviewTargetUnsupported) {
		// ErrReviewTargetUnsupported is a non-fallback sentinel: see the
		// matching carve-out in invokeRemoteEndpoint's session-link branch
		// for the rationale (product limitation, legacy path would
		// downgrade to 501, fallback would inflate metrics). Elastic and
		// session paths MUST classify identically so dashboards see the
		// same fallback=none outcome label regardless of route mode.
		recordRemoteEndpointRequest(manager, descriptor, "elastic_request_link", err, "none", time.Since(startedAt))
		return value, err
	}
	// Assignment send/wait failures are retried only for idempotent endpoints.
	fallbackStartedAt := time.Now()
	fallbackValue, fallbackErr := fallback()
	recordRemoteEndpointRequest(manager, descriptor, "elastic_request_link", err, "safe_retry", time.Since(startedAt))
	recordRemoteEndpointRequest(manager, descriptor, "legacy_session_link", fallbackErr, "safe_retry", time.Since(fallbackStartedAt))
	return fallbackValue, fallbackErr
}

// elasticFailureFallback is the recovery path when an ElasticPreferred
// endpoint fails to obtain or establish its elastic carrier mid-flight
// (open-command send failure, dial timeout, route dispatch miss). The
// resolution order mirrors stableOrFallbackDecision so both DecideRoute
// failures and post-decision failures classify identically:
//
//  1. Legacy Session Link command (FallbackCommand set): the daemon accepts
//     the typed legacy message directly.
//  2. Typed RemoteRequestAssignment envelope via Session Link (no legacy
//     command, ElasticPreferred policy, daemon advertises the endpoint and
//     supports session remote requests): the Session Link carries the data
//     plane while a new Elastic Request Link is being opened in the
//     background.
//  3. Stable business error: surfacing as HTTP 5xx with a distinguishable
//     stable code so the API layer does not mask it as a generic 500.
func (invocation RemoteEndpointInvocation[T]) elasticFailureFallback(stable RemoteRequestStableError, fallbackReason string) (T, error) {
	descriptor := invocation.Descriptor
	if descriptor.FallbackCommand != "" && descriptor.FallbackCommand != commandFallbackNone {
		startedAt := time.Now()
		value, err := invocation.Fallback()
		recordRemoteEndpointRequest(invocation.Manager, descriptor, "legacy_session_link", err, fallbackReason, time.Since(startedAt))
		return value, err
	}
	if descriptor.LinkPolicy == RemoteEndpointLinkPolicyElasticPreferred &&
		invocation.Client.canFallBackToSessionDataPlane(descriptor) {
		startedAt := time.Now()
		value, err := invokeViaSessionRemoteRequest(invocation.Context, invocation.Client, invocation.BuildSpec, invocation.MapResult, nil)
		recordRemoteEndpointRequest(invocation.Manager, descriptor, "session_link", err, fallbackReason, time.Since(startedAt))
		return value, err
	}
	var zero T
	err := remoteEndpointConnectionError(descriptor, stable, fallbackReason)
	recordRemoteEndpointRequest(invocation.Manager, descriptor, "none", err, "none", 0)
	return zero, err
}

func recordRemoteEndpointRequest(manager *ElasticRequestLinkManager, descriptor RemoteEndpointDescriptor, linkMode string, err error, fallback string, duration time.Duration) {
	if manager == nil || manager.config.Metrics == nil {
		return
	}
	manager.config.Metrics.RecordRemoteRequest(descriptor.MetricsName, linkMode, remoteEndpointOutcomeLabel(err), fallback, duration)
}

func remoteEndpointOutcomeLabel(err error) string {
	if err == nil {
		return "success"
	}
	outcome := strings.TrimSpace(err.Error())
	switch outcome {
	case string(RemoteRequestStableErrorDesktopBusy):
		return "busy"
	case string(RemoteRequestStableErrorDesktopUnavailable), "desktop_endpoint_unavailable":
		return "unavailable"
	case "deadline_exceeded", context.DeadlineExceeded.Error():
		return "timeout"
	case "canceled", context.Canceled.Error():
		return "canceled"
	default:
		return "failure"
	}
}

func invokeViaSessionRemoteRequest[T any](ctx context.Context, client *clientState, specBuilder func() (RemoteRequestAssignmentSpec, error), resultMapper func(RemoteRequestResult[json.RawMessage]) (T, error), afterSend func()) (T, error) {
	spec, err := specBuilder()
	if err != nil {
		var zero T
		return zero, err
	}
	envelope := NewRemoteRequestAssignment(spec)
	// DeadlineAt is the absolute cap shared with Desktop; the caller context may
	// cancel earlier, but this request must not outlive the advertised budget.
	requestCtx, cancel := context.WithDeadline(ctx, spec.DeadlineAt)
	defer cancel()
	result, err := client.remoteRequestTracker().Wait(requestCtx, spec.RemoteRequestID, func() error {
		if err := client.sessionLinkSender().Send(envelope); err != nil {
			return err
		}
		if afterSend != nil {
			afterSend()
		}
		return nil
	})
	if err != nil {
		var zero T
		return zero, err
	}
	return resultMapper(result)
}

func invokeViaElasticRequestLink[T any](ctx context.Context, manager *ElasticRequestLinkManager, linkID string, specBuilder func() (RemoteRequestAssignmentSpec, error), resultMapper func(RemoteRequestResult[json.RawMessage]) (T, error), afterAssign func()) (T, error) {
	spec, err := specBuilder()
	if err != nil {
		var zero T
		return zero, err
	}
	// DeadlineAt is the absolute cap shared with Desktop; the caller context may
	// cancel earlier, but this request must not outlive the advertised budget.
	requestCtx, cancel := context.WithDeadline(ctx, spec.DeadlineAt)
	defer cancel()
	resultCh, err := manager.AssignRemoteRequest(requestCtx, linkID, spec)
	if err != nil {
		var zero T
		return zero, err
	}
	if afterAssign != nil {
		afterAssign()
	}
	select {
	case <-requestCtx.Done():
		manager.CleanupAssignment(spec.RemoteRequestID, "canceled")
		var zero T
		return zero, requestCtx.Err()
	case result := <-resultCh:
		return resultMapper(result)
	}
}

func newWorkspaceListRemoteRequestSpec(ctx context.Context, descriptor RemoteEndpointDescriptor, agentID string, targetPath string, dirPath string, opts agent.WorkspaceListOptions) (RemoteRequestAssignmentSpec, error) {
	payload, err := json.Marshal(workspaceListRemoteRequestPayload{
		AgentID:    strings.TrimSpace(agentID),
		TargetPath: strings.TrimSpace(targetPath),
		DirPath:    dirPath,
		Limit:      opts.Limit,
		Cursor:     opts.Cursor,
	})
	if err != nil {
		return RemoteRequestAssignmentSpec{}, err
	}
	return newRemoteRequestAssignmentSpec(ctx, descriptor, "workspace_list", json.RawMessage(payload))
}

func newWorkspaceReadRemoteRequestSpec(ctx context.Context, descriptor RemoteEndpointDescriptor, agentID string, targetPath string, path string) (RemoteRequestAssignmentSpec, error) {
	payload, err := json.Marshal(workspaceReadRemoteRequestPayload{
		AgentID:    strings.TrimSpace(agentID),
		TargetPath: strings.TrimSpace(targetPath),
		Path:       strings.TrimSpace(path),
	})
	if err != nil {
		return RemoteRequestAssignmentSpec{}, err
	}
	return newRemoteRequestAssignmentSpec(ctx, descriptor, "workspace_read", json.RawMessage(payload))
}

func newWorkspaceApplySeedFilesRemoteRequestSpec(ctx context.Context, descriptor RemoteEndpointDescriptor, agentID string, seedFiles []agent.WorkspaceSeedFile) (RemoteRequestAssignmentSpec, error) {
	payload, err := json.Marshal(workspaceApplySeedFilesRemoteRequestPayload{
		AgentID:   strings.TrimSpace(agentID),
		SeedFiles: agentWorkspaceSeedFilesForDaemon(seedFiles),
	})
	if err != nil {
		return RemoteRequestAssignmentSpec{}, err
	}
	return newRemoteRequestAssignmentSpec(ctx, descriptor, "workspace_apply_seed_files", json.RawMessage(payload))
}

func newWorkspaceDeleteRemoteRequestSpec(ctx context.Context, descriptor RemoteEndpointDescriptor, agentID string, path string) (RemoteRequestAssignmentSpec, error) {
	payload, err := json.Marshal(workspaceDeleteRemoteRequestPayload{
		AgentID: strings.TrimSpace(agentID),
		Path:    strings.TrimSpace(path),
	})
	if err != nil {
		return RemoteRequestAssignmentSpec{}, err
	}
	return newRemoteRequestAssignmentSpec(ctx, descriptor, "workspace_delete", json.RawMessage(payload))
}

func newRemoteRequestAssignmentSpec(ctx context.Context, descriptor RemoteEndpointDescriptor, requestPrefix string, payload json.RawMessage) (RemoteRequestAssignmentSpec, error) {
	now := time.Now().UTC()
	// TimeoutBudget is derived from the earlier of endpoint timeout and caller
	// deadline. Desktop independently clamps execution to this relative budget.
	deadline := now.Add(descriptor.Timeout)
	if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
		deadline = ctxDeadline.UTC()
	}
	if !deadline.After(now) {
		return RemoteRequestAssignmentSpec{}, context.DeadlineExceeded
	}
	requestID := "rrq_" + strings.TrimSpace(requestPrefix) + "_" + strings.ReplaceAll(uuid.NewString(), "-", "")
	return RemoteRequestAssignmentSpec{
		RemoteRequestID:      requestID,
		EndpointID:           descriptor.EndpointID,
		EndpointVersion:      descriptor.Version,
		PayloadSchemaVersion: 1,
		Payload:              payload,
		DeadlineAt:           deadline,
		TimeoutBudget:        deadline.Sub(now),
		CancelToken:          "cancel_" + requestID,
		Limits:               descriptor.Limits,
	}, nil
}

func workspaceListPageFromRemoteResult(result RemoteRequestResult[json.RawMessage]) (agent.WorkspaceFilePage, error) {
	if err := remoteEndpointResultError(result); err != nil {
		return agent.WorkspaceFilePage{}, err
	}
	var response workspaceListRemoteRequestResponse
	if err := json.Unmarshal(result.Payload, &response); err != nil {
		return agent.WorkspaceFilePage{}, err
	}
	files, err := converter.FromDaemonWorkspaceFiles(response.Files)
	if err != nil {
		return agent.WorkspaceFilePage{}, err
	}
	return agent.WorkspaceFilePage{
		Files:      files,
		RootPath:   strings.TrimSpace(response.RootPath),
		Limit:      response.Limit,
		Cursor:     strings.TrimSpace(response.Cursor),
		HasMore:    response.HasMore,
		NextCursor: strings.TrimSpace(response.NextCursor),
	}, nil
}

func workspaceFileContentFromRemoteResult(result RemoteRequestResult[json.RawMessage]) (agent.WorkspaceFileContent, error) {
	if err := remoteEndpointResultError(result); err != nil {
		return agent.WorkspaceFileContent{}, err
	}
	var response workspaceReadRemoteRequestResponse
	if err := json.Unmarshal(result.Payload, &response); err != nil {
		return agent.WorkspaceFileContent{}, err
	}
	return agent.WorkspaceFileContent{Content: response.Content, Binary: response.Binary}, nil
}

func workspaceApplySeedFilesFromRemoteResult(result RemoteRequestResult[json.RawMessage]) (struct{}, error) {
	if err := remoteEndpointResultError(result); err != nil {
		return struct{}{}, err
	}
	var response workspaceApplySeedFilesRemoteRequestResponse
	if err := json.Unmarshal(result.Payload, &response); err != nil {
		return struct{}{}, err
	}
	if !response.Success {
		return struct{}{}, errors.New("workspace apply seed files failed")
	}
	return struct{}{}, nil
}

func workspaceDeleteFromRemoteResult(result RemoteRequestResult[json.RawMessage]) (struct{}, error) {
	if err := remoteEndpointResultError(result); err != nil {
		return struct{}{}, err
	}
	var response workspaceDeleteRemoteRequestResponse
	if err := json.Unmarshal(result.Payload, &response); err != nil {
		return struct{}{}, err
	}
	if !response.Success {
		return struct{}{}, errors.New("workspace delete failed")
	}
	return struct{}{}, nil
}

func remoteEndpointResultError(result RemoteRequestResult[json.RawMessage]) error {
	if result.Success {
		return nil
	}
	outcome := strings.TrimSpace(result.Outcome)
	if outcome == "" {
		outcome = strings.TrimSpace(result.Error)
	}
	if outcome == "" {
		outcome = "desktop_unavailable"
	}
	// Stable outcome → typed sentinel mapping. The Desktop handlers emit
	// these outcome strings to carry signal across the wire boundary that
	// would otherwise collapse every workspace error to a generic string.
	// Reconstructing the typed sentinel here lets channel/webagent map to
	// a controlled HTTP response (e.g. 422 for unsupported repo content)
	// instead of a uniform 500.
	if outcome == runtimecontrol.ReviewTargetUnsupportedOutcomeCode {
		return runtimecontrol.ErrReviewTargetUnsupported
	}
	return errors.New(outcome)
}

func remoteRequestStableError(stable RemoteRequestStableError) error {
	if stable == "" {
		stable = RemoteRequestStableErrorDesktopUnavailable
	}
	return &stableRemoteRequestError{code: stable}
}

func remoteEndpointConnectionError(descriptor RemoteEndpointDescriptor, stable RemoteRequestStableError, reason string) error {
	if stable == "" {
		stable = RemoteRequestStableErrorDesktopUnavailable
	}
	return &RemoteEndpointConnectionError{
		EndpointID: strings.TrimSpace(descriptor.EndpointID),
		Reason:     strings.TrimSpace(reason),
		Stable:     stable,
		cause:      remoteRequestStableError(stable),
	}
}

// RemoteEndpointConnectionError identifies remote endpoint carrier failures that
// could not fall back, while preserving the stable public error via Unwrap.
type RemoteEndpointConnectionError struct {
	EndpointID string
	Reason     string
	Stable     RemoteRequestStableError
	cause      error
}

func (e *RemoteEndpointConnectionError) Error() string {
	if e == nil || e.cause == nil {
		return string(RemoteRequestStableErrorDesktopUnavailable)
	}
	return e.cause.Error()
}

func (e *RemoteEndpointConnectionError) Unwrap() error {
	if e == nil {
		return nil
	}
	return e.cause
}

// stableRemoteRequestError is the typed form of RemoteRequestStableError.
// Returning a typed value (instead of errors.New(string(stable))) lets the
// directory post-classifier and any future classifier detect a stable code
// via errors.Is / errors.As WITHOUT reading an arbitrary error's text. The
// underlying code is a fixed enum, so no caller-supplied data ever reaches
// a typed sentinel through this type.
type stableRemoteRequestError struct {
	code RemoteRequestStableError
}

func (e *stableRemoteRequestError) Error() string {
	return string(e.code)
}

func (e *stableRemoteRequestError) Is(target error) bool {
	var t *stableRemoteRequestError
	if !errors.As(target, &t) {
		return false
	}
	return e.code == t.code
}
