package workspace

import (
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/gin-gonic/gin"

	"github.com/tidbcloud/AgentTeam/internal/api"
	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	"github.com/tidbcloud/AgentTeam/internal/api/dto/converter"
	"github.com/tidbcloud/AgentTeam/internal/runtimecontrol"
	workspaceservice "github.com/tidbcloud/AgentTeam/internal/service/workspace"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/app/runtime"
	"github.com/tidbcloud/AgentTeam/internal/versioning"
)

// MachineHandler owns machine administration and connect-token routes.
type MachineHandler struct {
	workspaceService    WorkspaceService
	runtime             MachineRuntime
	latestDaemonVersion versioning.Provider
	logger              *slog.Logger
}

// List handles GET /api/v1/workspaces/:workspaceId/machines.
// It returns machines for the specified workspace, filtered by the scope query parameter.
func (handler *MachineHandler) List(r api.AuthenticatedRequest) (api.Response, error) {

	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	if workspaceID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId is required")
	}

	scope := strings.TrimSpace(strings.ToLower(r.Gin.Query("scope")))
	rawLimit := r.Gin.Query("limit")
	cursor := strings.TrimSpace(r.Gin.Query("cursor"))
	usesPagedList := scope == "all" || strings.TrimSpace(rawLimit) != "" || cursor != ""
	listOptions := workspaceservice.MachineListOptions{Cursor: cursor}
	if usesPagedList {
		limit, err := parseMachineListLimit(rawLimit)
		if err != nil {
			return api.Response{}, err
		}
		listOptions.Limit = limit
	}

	var (
		page workspaceservice.MachineListPage
		err  error
	)
	switch scope {
	case "", "own":
		if usesPagedList {
			page, err = handler.workspaceService.ListOwnMachinesForWorkspacePage(
				r.Context(),
				r.UserID(),
				workspaceID,
				listOptions,
			)
		} else {
			var machines []workspaceservice.Machine
			machines, err = handler.workspaceService.ListOwnMachinesForWorkspace(
				r.Context(),
				r.UserID(),
				workspaceID,
			)
			page = workspaceservice.MachineListPage{Machines: machines}
		}
	case "all":
		page, err = handler.workspaceService.ListAllMachinesForWorkspacePage(
			r.Context(),
			r.UserID(),
			workspaceID,
			listOptions,
		)
	default:
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "scope must be own or all")
	}
	if err != nil {
		return api.Response{}, err
	}
	handler.applyMachineRuntimeStatus(page.Machines)

	latestDaemonVersion := ""
	if handler.latestDaemonVersion != nil {
		latest, err := handler.latestDaemonVersion.Latest(r.Context())
		if err != nil {
			if handler.logger != nil {
				handler.logger.Warn("fetch latest daemon version", slog.Any("error", err))
			}
		}
		latestDaemonVersion = latest
	}

	return api.OK(dto.MachinesListResponse{
		Machines:            converter.ToMachines(toMachineViews(page.Machines)),
		LatestDaemonVersion: latestDaemonVersion,
		HasMore:             page.HasMore,
		NextCursor:          page.NextCursor,
	}), nil
}

func (handler *MachineHandler) applyMachineRuntimeStatus(machines []workspaceservice.Machine) {
	if handler.runtime == nil {
		return
	}
	for index := range machines {
		if !handler.runtime.MachineConnected(machines[index].ID) {
			machines[index].Status = "offline"
			continue
		}
		machines[index].Status = "online"
		machines[index].DirectoryBrowseSupported = handler.runtime.MachineDirectoryBrowseSupported(machines[index].ID)
		machines[index].DashboardSupported = handler.runtime.MachineDashboardSupported(machines[index].ID)
		runtimes, defaults, ok := handler.runtime.MachineRuntimeCatalog(machines[index].ID)
		if !ok {
			continue
		}
		machines[index].Runtimes = append([]string(nil), runtimes...)
		machines[index].RuntimeDefaults = toWorkspaceRuntimeDefaults(defaults)
	}
}

func toWorkspaceRuntimeDefaults(defaults map[string]runtime.RuntimeDefaults) map[string]workspaceservice.RuntimeDefaults {
	if len(defaults) == 0 {
		return map[string]workspaceservice.RuntimeDefaults{}
	}
	out := make(map[string]workspaceservice.RuntimeDefaults, len(defaults))
	for runtimeName, value := range defaults {
		out[runtimeName] = workspaceservice.RuntimeDefaults{
			Model:           value.Model,
			ReasoningEffort: value.ReasoningEffort,
		}
	}
	return out
}

// Agents handles GET /api/v1/workspaces/:workspaceId/machines/:machineId/agents.
// It returns agents scoped to one machine in the workspace.
func (handler *MachineHandler) Agents(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId and machineId are required")
	}

	agents, err := handler.workspaceService.ListAgentsForMachineWorkspace(r.Context(), r.UserID(), workspaceID, machineID)
	if err != nil {
		return api.Response{}, err
	}
	handler.applyAgentRuntimeStatus(agents)
	return api.OK(converter.ToAppAgents(agents)), nil
}

func parseMachineListLimit(rawLimit string) (int, error) {
	trimmed := strings.TrimSpace(rawLimit)
	if trimmed == "" {
		return workspaceservice.DefaultMachineListLimit, nil
	}
	limit, err := strconv.Atoi(trimmed)
	if err != nil {
		return 0, api.NewHTTPError(http.StatusBadRequest, "limit must be an integer")
	}
	if limit <= 0 {
		return 0, api.NewHTTPError(http.StatusBadRequest, "limit must be a positive integer")
	}
	if limit > workspaceservice.MaxMachineListLimit {
		return workspaceservice.MaxMachineListLimit, nil
	}
	return limit, nil
}

// Dashboard handles GET /api/v1/workspaces/:workspaceId/machines/:machineId/dashboard.
// It returns metrics collected by the connected Desktop daemon for this Agent Host.
func (handler *MachineHandler) Dashboard(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId and machineId are required")
	}

	agents, err := handler.workspaceService.ListAgentsForMachineWorkspace(r.Context(), r.UserID(), workspaceID, machineID)
	if err != nil {
		return api.Response{}, err
	}
	agentIDs, err := selectMachineDashboardAgentIDs(agents, r.Gin.QueryArray("agentId"))
	if err != nil {
		return api.Response{}, err
	}
	window, err := parseMachineDashboardWindow(r.Gin.DefaultQuery("window", string(runtimecontrol.MachineDashboardWindow1Day)))
	if err != nil {
		return api.Response{}, err
	}
	if handler.runtime == nil || !handler.runtime.MachineConnected(machineID) {
		return api.Response{}, runtimecontrol.ErrMachineDashboardUnavailable
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	dashboard, err := handler.runtime.QueryMachineDashboard(requestCtx, machineID, runtimecontrol.MachineDashboardRequest{
		WorkspaceID: workspaceID,
		MachineID:   machineID,
		Window:      window,
		AgentIDs:    agentIDs,
	})
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(dashboard), nil
}

func parseMachineDashboardWindow(value string) (runtimecontrol.MachineDashboardWindow, error) {
	switch strings.TrimSpace(value) {
	case "", string(runtimecontrol.MachineDashboardWindow1Day):
		return runtimecontrol.MachineDashboardWindow1Day, nil
	case string(runtimecontrol.MachineDashboardWindow6Hours):
		return runtimecontrol.MachineDashboardWindow6Hours, nil
	case string(runtimecontrol.MachineDashboardWindow12Hours):
		return runtimecontrol.MachineDashboardWindow12Hours, nil
	case string(runtimecontrol.MachineDashboardWindow3Days):
		return runtimecontrol.MachineDashboardWindow3Days, nil
	case string(runtimecontrol.MachineDashboardWindow1Week):
		return runtimecontrol.MachineDashboardWindow1Week, nil
	default:
		return "", api.NewHTTPError(http.StatusBadRequest, "window must be one of: 6h, 12h, 1d, 3d, 1w")
	}
}

// machineSettingsRequestBytes caps the JSON body accepted by machine settings
// and MCP server upsert endpoints. Mirrors the elastic RequestBytes budget so
// the API rejects oversized bodies before they reach the daemon.
const machineSettingsRequestBytes = 128 * 1024

// machineRuntimeConfigRequestBytes caps the JSON body accepted by runtime
// config update / validate endpoints.
const machineRuntimeConfigRequestBytes = 128 * 1024

// machineMCPServerUpsertRequestBytes caps the JSON body accepted by MCP server
// upsert requests.
const machineMCPServerUpsertRequestBytes = 256 * 1024

// GetSettings handles GET /api/v1/workspaces/:workspaceId/machines/:machineId/settings.
// It forwards the request to the connected daemon's machine.settings endpoint
// and returns the daemon-authored JSON bundle verbatim.
func (handler *MachineHandler) GetSettings(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId and machineId are required")
	}
	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}
	if handler.runtime == nil || !handler.runtime.MachineConnected(machineID) {
		return api.Response{}, runtimecontrol.ErrMachineSettingsUnavailable
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	payload, err := handler.runtime.QueryMachineSettings(requestCtx, workspaceID, machineID)
	if err != nil {
		return api.Response{}, err
	}
	return api.Response{Status: http.StatusOK, Body: payload}, nil
}

// UpdateSettings handles PUT /api/v1/workspaces/:workspaceId/machines/:machineId/settings.
// Only the desktop sub-field of the body is mutable; the daemon enforces the
// read-only-ness of system and daemon sub-fields.
func (handler *MachineHandler) UpdateSettings(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId and machineId are required")
	}
	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}
	desktop, err := readMachineDetailRequestBody(r.Gin, machineSettingsRequestBytes)
	if err != nil {
		return api.Response{}, err
	}
	if handler.runtime == nil || !handler.runtime.MachineConnected(machineID) {
		return api.Response{}, runtimecontrol.ErrMachineSettingsUnavailable
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	payload, err := handler.runtime.UpdateMachineSettings(requestCtx, workspaceID, machineID, desktop)
	if err != nil {
		return api.Response{}, err
	}
	return api.Response{Status: http.StatusOK, Body: payload}, nil
}

// ListRuntimes handles GET /api/v1/workspaces/:workspaceId/machines/:machineId/runtimes.
// It returns the daemon's runtimes bundle (status + detected runtimes) as raw
// JSON so the daemon remains the single source of truth for the schema.
func (handler *MachineHandler) ListRuntimes(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId and machineId are required")
	}
	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}
	if handler.runtime == nil || !handler.runtime.MachineConnected(machineID) {
		return api.Response{}, runtimecontrol.ErrMachineRuntimesUnavailable
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	payload, err := handler.runtime.QueryMachineRuntimesBundle(requestCtx, workspaceID, machineID)
	if err != nil {
		return api.Response{}, err
	}
	return api.Response{Status: http.StatusOK, Body: payload}, nil
}

// DetectRuntimes handles POST /api/v1/workspaces/:workspaceId/machines/:machineId/runtimes/detect.
// It triggers a fresh PATH detection pass on the daemon and returns the
// refreshed bundle.
func (handler *MachineHandler) DetectRuntimes(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId and machineId are required")
	}
	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}
	if handler.runtime == nil || !handler.runtime.MachineConnected(machineID) {
		return api.Response{}, runtimecontrol.ErrMachineRuntimesUnavailable
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	payload, err := handler.runtime.DetectMachineRuntimes(requestCtx, workspaceID, machineID)
	if err != nil {
		return api.Response{}, err
	}
	return api.Response{Status: http.StatusOK, Body: payload}, nil
}

// GetRuntimeConfig handles GET /api/v1/workspaces/:workspaceId/machines/:machineId/runtimes/:name/config.
func (handler *MachineHandler) GetRuntimeConfig(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	name := strings.TrimSpace(r.Gin.Param("name"))
	if workspaceID == "" || machineID == "" || name == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId, machineId and name are required")
	}
	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}
	if handler.runtime == nil || !handler.runtime.MachineConnected(machineID) {
		return api.Response{}, runtimecontrol.ErrMachineRuntimesUnavailable
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	payload, err := handler.runtime.QueryMachineRuntimeConfig(requestCtx, workspaceID, machineID, name)
	if err != nil {
		return api.Response{}, err
	}
	return api.Response{Status: http.StatusOK, Body: payload}, nil
}

// UpdateRuntimeConfig handles PUT /api/v1/workspaces/:workspaceId/machines/:machineId/runtimes/:name/config.
func (handler *MachineHandler) UpdateRuntimeConfig(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	name := strings.TrimSpace(r.Gin.Param("name"))
	if workspaceID == "" || machineID == "" || name == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId, machineId and name are required")
	}
	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}
	config, err := readMachineDetailRequestBody(r.Gin, machineRuntimeConfigRequestBytes)
	if err != nil {
		return api.Response{}, err
	}
	if handler.runtime == nil || !handler.runtime.MachineConnected(machineID) {
		return api.Response{}, runtimecontrol.ErrMachineRuntimesUnavailable
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	payload, err := handler.runtime.UpdateMachineRuntimeConfig(requestCtx, workspaceID, machineID, name, config)
	if err != nil {
		return api.Response{}, err
	}
	return api.Response{Status: http.StatusOK, Body: payload}, nil
}

// ValidateRuntimeConfig handles POST
// /api/v1/workspaces/:workspaceId/machines/:machineId/runtimes/:name/config/validate.
// It is a side-effect-free probe; the daemon runs both offline and online
// validation passes and returns the combined result.
func (handler *MachineHandler) ValidateRuntimeConfig(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	name := strings.TrimSpace(r.Gin.Param("name"))
	if workspaceID == "" || machineID == "" || name == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId, machineId and name are required")
	}
	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}
	config, err := readMachineDetailRequestBody(r.Gin, machineRuntimeConfigRequestBytes)
	if err != nil {
		return api.Response{}, err
	}
	if handler.runtime == nil || !handler.runtime.MachineConnected(machineID) {
		return api.Response{}, runtimecontrol.ErrMachineRuntimesUnavailable
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	payload, err := handler.runtime.ValidateMachineRuntimeConfig(requestCtx, workspaceID, machineID, name, config)
	if err != nil {
		return api.Response{}, err
	}
	return api.Response{Status: http.StatusOK, Body: payload}, nil
}

// ListMCPServers handles GET /api/v1/workspaces/:workspaceId/machines/:machineId/mcp-servers.
func (handler *MachineHandler) ListMCPServers(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId and machineId are required")
	}
	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}
	if handler.runtime == nil || !handler.runtime.MachineConnected(machineID) {
		return api.Response{}, runtimecontrol.ErrMachineMCPServersUnavailable
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	payload, err := handler.runtime.QueryMachineMCPServers(requestCtx, workspaceID, machineID)
	if err != nil {
		return api.Response{}, err
	}
	return api.Response{Status: http.StatusOK, Body: payload}, nil
}

// UpsertMCPServer handles POST /api/v1/workspaces/:workspaceId/machines/:machineId/mcp-servers.
// The request body is the MCP server config (transport + http/stdio fields).
func (handler *MachineHandler) UpsertMCPServer(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId and machineId are required")
	}
	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}
	server, err := readMachineDetailRequestBody(r.Gin, machineMCPServerUpsertRequestBytes)
	if err != nil {
		return api.Response{}, err
	}
	if handler.runtime == nil || !handler.runtime.MachineConnected(machineID) {
		return api.Response{}, runtimecontrol.ErrMachineMCPServersUnavailable
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	payload, err := handler.runtime.UpsertMachineMCPServer(requestCtx, workspaceID, machineID, server)
	if err != nil {
		return api.Response{}, err
	}
	return api.Response{Status: http.StatusOK, Body: payload}, nil
}

// DeleteMCPServer handles DELETE /api/v1/workspaces/:workspaceId/machines/:machineId/mcp-servers/:name.
func (handler *MachineHandler) DeleteMCPServer(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	name := strings.TrimSpace(r.Gin.Param("name"))
	if workspaceID == "" || machineID == "" || name == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId, machineId and name are required")
	}
	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}
	if handler.runtime == nil || !handler.runtime.MachineConnected(machineID) {
		return api.Response{}, runtimecontrol.ErrMachineMCPServersUnavailable
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	payload, err := handler.runtime.DeleteMachineMCPServer(requestCtx, workspaceID, machineID, name)
	if err != nil {
		return api.Response{}, err
	}
	return api.Response{Status: http.StatusOK, Body: payload}, nil
}

// TestMCPServer handles POST /api/v1/workspaces/:workspaceId/machines/:machineId/mcp-servers/:name/test.
// It probes connectivity for one saved MCP server config.
func (handler *MachineHandler) TestMCPServer(r api.AuthenticatedRequest) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	name := strings.TrimSpace(r.Gin.Param("name"))
	if workspaceID == "" || machineID == "" || name == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId, machineId and name are required")
	}
	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}
	if handler.runtime == nil || !handler.runtime.MachineConnected(machineID) {
		return api.Response{}, runtimecontrol.ErrMachineMCPServersUnavailable
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	payload, err := handler.runtime.TestMachineMCPServer(requestCtx, workspaceID, machineID, name)
	if err != nil {
		return api.Response{}, err
	}
	return api.Response{Status: http.StatusOK, Body: payload}, nil
}

// readMachineDetailRequestBody reads and validates the JSON body sent to a
// Machine Detail write endpoint. It returns the raw JSON bytes (the daemon
// owns the schema) and rejects oversized or syntactically invalid bodies
// before they reach the daemon. The body is never echoed on error.
func readMachineDetailRequestBody(ctx *gin.Context, maxBytes int64) (json.RawMessage, error) {
	body, err := io.ReadAll(http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxBytes))
	if err != nil {
		return nil, api.NewHTTPError(http.StatusRequestEntityTooLarge, "request body too large")
	}
	trimmed := strings.TrimSpace(string(body))
	if trimmed == "" {
		return nil, api.NewHTTPError(http.StatusBadRequest, "request body is required")
	}
	if !json.Valid(body) {
		return nil, api.NewHTTPError(http.StatusBadRequest, "invalid request body")
	}
	return body, nil
}

func selectMachineDashboardAgentIDs(agents []agent.AppAgent, rawAgentIDs []string) ([]string, error) {
	allowed := make(map[string]struct{}, len(agents))
	for _, item := range agents {
		if id := strings.TrimSpace(item.ID); id != "" {
			allowed[id] = struct{}{}
		}
	}
	if len(rawAgentIDs) == 0 {
		ids := make([]string, 0, len(agents))
		for _, item := range agents {
			if id := strings.TrimSpace(item.ID); id != "" {
				ids = append(ids, id)
			}
		}
		return ids, nil
	}
	seen := make(map[string]struct{}, len(rawAgentIDs))
	ids := make([]string, 0, len(rawAgentIDs))
	for _, raw := range rawAgentIDs {
		id := strings.TrimSpace(raw)
		if id == "" {
			continue
		}
		if _, exists := seen[id]; exists {
			continue
		}
		if _, ok := allowed[id]; !ok {
			return nil, api.NewHTTPError(http.StatusBadRequest, "agentId does not belong to this machine")
		}
		seen[id] = struct{}{}
		ids = append(ids, id)
	}
	if len(ids) == 0 {
		return nil, api.NewHTTPError(http.StatusBadRequest, "at least one valid agentId is required")
	}
	return ids, nil
}

// Create handles POST /api/v1/workspaces/:workspaceId/machines.
// It creates a new machine for the specified workspace and returns the API key.
func (handler *MachineHandler) Create(r api.AuthenticatedJSON[dto.MachineNameRequest]) (api.Response, error) {

	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	if workspaceID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "workspaceId is required")
	}

	request := r.Body

	machine, apiKey, err := handler.workspaceService.CreateMachineForWorkspace(r.Context(), r.UserID(), workspaceID, request.Name)
	if err != nil {
		return api.Response{}, err
	}

	return api.OK(dto.CreateMachineResponse{Machine: converter.ToMachine(toMachineView(machine)), APIKey: apiKey}), nil
}

// Rename handles PATCH /api/v1/workspaces/:workspaceId/machines/:machineId.
// It renames the specified machine.
func (handler *MachineHandler) Rename(r api.AuthenticatedJSON[dto.MachineNameRequest]) (api.Response, error) {

	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "Invalid request")
	}

	request := r.Body

	if err := handler.workspaceService.RenameMachineForWorkspace(r.Context(), r.UserID(), workspaceID, machineID, request.Name); err != nil {
		return api.Response{}, err
	}
	return api.OK(dto.EmptyResponse{}), nil
}

// Delete handles DELETE /api/v1/workspaces/:workspaceId/machines/:machineId.
// It deletes the specified machine from the workspace.
func (handler *MachineHandler) Delete(r api.AuthenticatedRequest) (api.Response, error) {

	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "Invalid request")
	}

	if err := handler.workspaceService.DeleteMachineForWorkspace(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}
	if handler.runtime != nil {
		handler.runtime.DisconnectMachine(machineID)
	}
	return api.OK(dto.EmptyResponse{}), nil
}

// RotateKey handles POST /api/v1/workspaces/:workspaceId/machines/:machineId/api-key-rotations.
// It rotates the API key for the specified machine and disconnects the daemon.
func (handler *MachineHandler) RotateKey(r api.AuthenticatedRequest) (api.Response, error) {

	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "Invalid request")
	}

	apiKey, err := handler.workspaceService.RotateMachineKeyForUser(r.Context(), r.UserID(), workspaceID, machineID)
	if err != nil {
		return api.Response{}, err
	}
	if handler.runtime != nil {
		handler.runtime.DisconnectMachine(machineID)
	}

	return api.OK(dto.MachineRotateKeyResponse{APIKey: apiKey}), nil
}

// ListWorkspaceDirectories handles GET /api/v1/workspaces/:workspaceId/machines/:machineId/workspace-directories.
// It scans and returns the workspace directories on the specified machine.
func (handler *MachineHandler) ListWorkspaceDirectories(r api.AuthenticatedRequest) (api.Response, error) {

	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "Invalid request")
	}

	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}

	if handler.runtime == nil {
		return api.Response{}, api.NewHTTPError(http.StatusServiceUnavailable, "Machine is offline")
	}

	scanOptions, err := parseMachineWorkspaceListOptions(r.Gin.Query("limit"), r.Gin.Query("cursor"))
	if err != nil {
		return api.Response{}, err
	}
	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	page, err := handler.runtime.ScanMachineWorkspacePage(requestCtx, machineID, scanOptions)
	if err != nil {
		return api.Response{}, err
	}

	agents, err := handler.workspaceService.ListAgentsForMachineWorkspace(r.Context(), r.UserID(), workspaceID, machineID)
	if err != nil {
		return api.Response{}, err
	}
	handler.applyAgentRuntimeStatus(agents)

	type agentMeta struct {
		Name   string
		Status string
	}
	agentByID := make(map[string]agentMeta, len(agents))
	for _, agent := range agents {
		name := agentDisplayName(agent.Name, agent.DisplayName)
		agentByID[agent.ID] = agentMeta{Name: name, Status: strings.TrimSpace(agent.Status)}
	}

	workspaces := make([]dto.MachineWorkspaceResponse, 0, len(page.Directories))
	for _, directory := range page.Directories {
		workspace := dto.MachineWorkspaceResponse{
			DirectoryName:  directory.DirectoryName,
			TotalSizeBytes: directory.TotalSizeBytes,
			LastModified:   directory.LastModified,
			FileCount:      directory.FileCount,
		}

		if agent, ok := agentByID[directory.DirectoryName]; ok {
			workspace.AgentName = agent.Name
			if agent.Status == "active" {
				workspace.Status = "active"
			} else {
				workspace.Status = "stopped"
			}
		} else {
			workspace.Status = "orphan"
		}

		workspaces = append(workspaces, workspace)
	}

	return api.OK(dto.MachineWorkspacesListResponse{
		Workspaces: workspaces,
		Limit:      page.Limit,
		Cursor:     page.Cursor,
		HasMore:    page.HasMore,
		NextCursor: page.NextCursor,
	}), nil
}

func (handler *MachineHandler) applyAgentRuntimeStatus(agents []agent.AppAgent) {
	runtime, ok := handler.runtime.(api.AgentRuntimeOverlay)
	if !ok {
		return
	}
	api.OverlayAppAgentsRuntime(agents, runtime, time.Now().UTC())
}

func parseMachineWorkspaceListOptions(rawLimit string, rawCursor string) (agent.WorkspaceListOptions, error) {
	opts := agent.WorkspaceListOptions{
		Limit:  agent.DefaultWorkspaceListLimit,
		Cursor: strings.TrimSpace(rawCursor),
	}
	trimmedLimit := strings.TrimSpace(rawLimit)
	if trimmedLimit == "" {
		return opts, nil
	}
	limit, err := strconv.Atoi(trimmedLimit)
	if err != nil {
		return agent.WorkspaceListOptions{}, api.NewHTTPError(http.StatusBadRequest, "limit must be an integer")
	}
	if limit <= 0 {
		return agent.WorkspaceListOptions{}, api.NewHTTPError(http.StatusBadRequest, "limit must be a positive integer")
	}
	if limit > agent.MaxWorkspaceListLimit {
		limit = agent.MaxWorkspaceListLimit
	}
	opts.Limit = limit
	return opts, nil
}

// DeleteWorkspaceDirectory handles DELETE /api/v1/workspaces/:workspaceId/machines/:machineId/workspace-directories/:directoryName.
// It deletes a workspace directory from the specified machine.
func (handler *MachineHandler) DeleteWorkspaceDirectory(r api.AuthenticatedRequest) (api.Response, error) {

	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	directoryName := strings.TrimSpace(r.Gin.Param("directoryName"))
	if workspaceID == "" || machineID == "" || directoryName == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "Invalid request")
	}

	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}

	if handler.runtime == nil {
		return api.Response{}, api.NewHTTPError(http.StatusServiceUnavailable, "Machine is offline")
	}

	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	deleted, err := handler.runtime.DeleteMachineWorkspaceDirectory(requestCtx, machineID, directoryName)
	if err != nil {
		return api.Response{}, err
	}
	if !deleted {
		return api.Response{}, api.NewHTTPError(http.StatusNotFound, "Workspace not found")
	}

	return api.OK(dto.EmptyResponse{}), nil
}

// ListDirectories handles POST
// /api/v1/workspaces/:workspaceId/machines/:machineId/directories:list.
//
// Auth-first: RequireMachineManagementForUser runs BEFORE the runtime-nil
// check so a missing runtime never masks an authorization failure. Path is
// forwarded byte-for-byte (no TrimSpace) — the daemon is the single
// authority on what "valid" means. Limit defaults to 50 and caps at 200;
// cursor accepts only canonical raw offset form ("", "0", or [1-9][0-9]*
// up to 1000) so the daemon's strict cursor contract is preserved.
func (handler *MachineHandler) ListDirectories(r api.AuthenticatedJSON[dto.MachineDirectoryListRequest]) (api.Response, error) {
	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "Invalid request")
	}

	if err := handler.workspaceService.RequireMachineManagementForUser(r.Context(), r.UserID(), workspaceID, machineID); err != nil {
		return api.Response{}, err
	}

	if handler.runtime == nil {
		return api.Response{}, runtimecontrol.ErrMachineDirectoryUnavailable
	}

	opts, err := parseMachineDirectoryListOptions(r.Body.Limit, r.Body.Cursor)
	if err != nil {
		return api.Response{}, err
	}

	requestCtx := runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
	page, err := handler.runtime.ListMachineDirectoriesPage(requestCtx, machineID, r.Body.Path, opts)
	if err != nil {
		return api.Response{}, err
	}

	return api.OK(dto.MachineDirectoryListResponse{
		CurrentPath: page.CurrentPath,
		ParentPath:  page.ParentPath,
		Directories: toMachineDirectoryResponses(page.Directories),
		Limit:       page.Limit,
		Cursor:      page.Cursor,
		HasMore:     page.HasMore,
		NextCursor:  page.NextCursor,
		Truncated:   page.Truncated,
	}), nil
}

// machineDirectoryListLimitDefault matches the daemon's workspace list page
// size so a default request renders a useful first page without round-trips.
const machineDirectoryListLimitDefault = 50

// machineDirectoryListLimitMax caps user-supplied limit so a single request
// can never ask the daemon to enumerate more directories than the workspace
// list contract allows.
const machineDirectoryListLimitMax = 200

// machineDirectoryListCursorMax bounds the raw offset the API accepts; the
// daemon's own contract permits up to 1000 raw entries per scan, so any
// larger cursor is by construction out-of-range.
const machineDirectoryListCursorMax = 1000

// parseMachineDirectoryListOptions validates limit + cursor with a strict,
// directory-specific parser. Unlike parseMachineWorkspaceListOptions it
// does NOT TrimSpace the cursor: whitespace, leading zeros, negatives,
// and over-budget values all return machine_directory_invalid so the
// daemon's strict cursor contract is preserved end-to-end.
func parseMachineDirectoryListOptions(rawLimit int, rawCursor string) (agent.WorkspaceListOptions, error) {
	limit := rawLimit
	if limit <= 0 {
		limit = machineDirectoryListLimitDefault
	}
	if limit > machineDirectoryListLimitMax {
		limit = machineDirectoryListLimitMax
	}

	cursor := rawCursor
	if cursor != "" {
		if !isRawOffsetCursor(cursor) {
			return agent.WorkspaceListOptions{}, runtimecontrol.ErrMachineDirectoryInvalid
		}
	}

	return agent.WorkspaceListOptions{Limit: limit, Cursor: cursor}, nil
}

// isRawOffsetCursor reports whether cursor is the canonical
// raw offset form: "", "0", or [1-9][0-9]* up to 1000. Whitespace, leading
// zeros, non-digit characters, negatives, and values above the cap are
// rejected. The grammar is small enough to inline; no regex needed.
func isRawOffsetCursor(cursor string) bool {
	if cursor == "" {
		return true
	}
	if cursor == "0" {
		return true
	}
	if len(cursor) > 4 {
		return false
	}
	for i := 0; i < len(cursor); i++ {
		c := cursor[i]
		if c < '0' || c > '9' {
			return false
		}
	}
	if cursor[0] == '0' {
		return false
	}
	value := 0
	for i := 0; i < len(cursor); i++ {
		value = value*10 + int(cursor[i]-'0')
	}
	return value >= 1 && value <= machineDirectoryListCursorMax
}

func toMachineDirectoryResponses(directories []agent.MachineDirectory) []dto.MachineDirectoryResponse {
	if directories == nil {
		return []dto.MachineDirectoryResponse{}
	}
	out := make([]dto.MachineDirectoryResponse, 0, len(directories))
	for _, directory := range directories {
		out = append(out, dto.MachineDirectoryResponse{Name: directory.Name, Path: directory.Path})
	}
	return out
}

// limitMachineDirectoryListBody is the gin middleware that caps the
// JSON body at 64 KiB before the handler adapter attempts to bind it.
// Oversized bodies are rejected with the standard 413 contract; invalid
// JSON with the standard 400 contract. The body itself is never echoed.
func limitMachineDirectoryListBody(ctx *gin.Context) {
	const maxBytes = 64 * 1024
	ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxBytes)
	ctx.Next()
}

// Evict handles POST /api/v1/workspaces/:workspaceId/machines/:machineId/evictions.
// It evicts the specified machine from the workspace and disconnects its daemon.
func (handler *MachineHandler) Evict(r api.AuthenticatedRequest) (api.Response, error) {

	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "Invalid request")
	}

	affectedAgents, err := handler.workspaceService.EvictMachineForWorkspace(r.Context(), r.UserID(), workspaceID, machineID)
	if err != nil {
		return api.Response{}, err
	}

	// Disconnect the machine's daemon connection
	if handler.runtime != nil {
		handler.runtime.DisconnectMachine(machineID)
	}

	return api.OK(dto.MachineEvictResponse{
		Evicted:        true,
		MachineID:      machineID,
		AffectedAgents: affectedAgents,
	}), nil
}

// ConnectToken handles POST /api/v1/workspaces/:workspaceId/machines/:machineId/connect-tokens.
// It creates a one-time connect token for the specified machine.
func (handler *MachineHandler) ConnectToken(r api.AuthenticatedJSON[dto.MachineConnectTokenRequest]) (api.Response, error) {

	workspaceID := strings.TrimSpace(r.Gin.Param("workspaceId"))
	machineID := strings.TrimSpace(r.Gin.Param("machineId"))
	if workspaceID == "" || machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "Invalid request")
	}

	request := r.Body

	token, err := handler.workspaceService.CreateMachineConnectToken(r.Context(), r.UserID(), workspaceID, machineID, request.ServerURL)
	if err != nil {
		return api.Response{}, err
	}

	return api.OK(dto.MachineConnectTokenResponse{Token: token}), nil
}

// ConnectExchange handles POST /api/v1/machine-connect-exchanges.
// It exchanges a one-time connect token for machine credentials.
func (handler *MachineHandler) ConnectExchange(ctx *gin.Context) {
	var request dto.MachineConnectExchangeRequest
	if err := ctx.ShouldBindJSON(&request); err != nil {
		api.Error(ctx, http.StatusBadRequest, "Invalid request body")
		return
	}

	result, err := handler.workspaceService.ExchangeMachineConnectToken(ctx.Request.Context(), request.Token)
	if err != nil {
		api.StoreError(ctx, err)
		return
	}

	ctx.JSON(http.StatusOK, dto.MachineConnectExchangeResponse{
		MachineID:   result.MachineID,
		APIKey:      result.APIKey,
		ServerURL:   result.ServerURL,
		WorkspaceID: result.WorkspaceID,
	})
}

// ResolveAuth handles POST /api/v1/machine-auth-resolutions.
// It resolves a machine API key into the machine/workspace identity needed by Desktop auth.
func (handler *MachineHandler) ResolveAuth(ctx *gin.Context) {
	var request dto.MachineAuthResolveRequest
	if err := ctx.ShouldBindJSON(&request); err != nil {
		api.Error(ctx, http.StatusBadRequest, "Invalid request body")
		return
	}

	result, err := handler.workspaceService.ResolveMachineAuth(ctx.Request.Context(), request.APIKey, request.ServerURL)
	if err != nil {
		api.StoreError(ctx, err)
		return
	}

	ctx.JSON(http.StatusOK, dto.MachineAuthResolveResponse{
		MachineID:   result.MachineID,
		APIKey:      result.APIKey,
		ServerURL:   result.ServerURL,
		WorkspaceID: result.WorkspaceID,
	})
}

func agentDisplayName(name string, displayName string) string {
	trimmed := strings.TrimSpace(displayName)
	if trimmed == "" {
		trimmed = strings.TrimSpace(name)
	}
	return trimmed
}
