// Package acp handler.go exposes the ACP module's HTTP / WS endpoints.
// Part 1: agent_kinds routes. Part 2 (G1/G2): sessions REST endpoints.
// Part 3 (G3): WebSocket upgrader.
package acp

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

	"github.com/go-chi/chi/v5"
	"github.com/google/uuid"

	ws "github.com/coder/websocket"
	"github.com/coder/websocket/wsjson"

	"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
	"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
	httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
	"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
)

// AdminLookup resolves whether a user is an admin (mirrors chat/workspace pattern).
type AdminLookup interface {
	IsAdmin(ctx context.Context, userID uuid.UUID) (bool, error)
}

// WSConfig controls WebSocket heartbeat and buffering.
type WSConfig struct {
	PingInterval time.Duration
	PongTimeout  time.Duration
	SendBuffer   int
}

// Handler exposes ACP HTTP endpoints.
type Handler struct {
	akSvc       AgentKindService
	sessSvc     SessionService
	sup         *Supervisor
	repo        Repository
	permSvc     *PermissionService
	resolver    middleware.SessionResolver
	adminLookup AdminLookup
	enc         *crypto.Encryptor
	cfg         WSConfig
}

// NewHandler constructs Handler with full dependencies.
func NewHandler(ak AgentKindService, ss SessionService, sup *Supervisor, repo Repository,
	permSvc *PermissionService, resolver middleware.SessionResolver, al AdminLookup,
	enc *crypto.Encryptor, cfg WSConfig) *Handler {
	return &Handler{
		akSvc: ak, sessSvc: ss, sup: sup, repo: repo, permSvc: permSvc,
		resolver: resolver, adminLookup: al, enc: enc, cfg: cfg,
	}
}

// Mount registers /api/v1/acp/* routes.
func (h *Handler) Mount(r chi.Router) {
	r.Route("/api/v1/acp/agent-kinds", func(r chi.Router) {
		r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
		r.Get("/", h.listAgentKinds)
		r.Post("/", h.createAgentKind)
		r.Get("/{id}", h.getAgentKind)
		r.Patch("/{id}", h.updateAgentKind)
		r.Delete("/{id}", h.deleteAgentKind)
		// 配置文件管理（admin only，service 层鉴权）。rel_path 含斜杠，
		// 不放 URL path，统一走 body / query。
		r.Get("/{id}/config-files", h.listConfigFiles)
		r.Put("/{id}/config-files", h.upsertConfigFile)
		r.Delete("/{id}/config-files", h.deleteConfigFile)
	})
	r.Route("/api/v1/acp/sessions", func(r chi.Router) {
		r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
		r.Get("/", h.listSessions)
		r.Post("/", h.createSession)
		r.Get("/{id}", h.getSession)
		r.Delete("/{id}", h.terminateSession)
		r.Get("/{id}/events", h.getEvents)
		r.Get("/{id}/ws", h.sessionWS)
		r.Get("/{id}/permissions", h.listSessionPermissions)
		r.Get("/{id}/usage", h.getSessionUsage)
		// run-history 单会话事件时间线（owner 或 admin）。
		r.Get("/{id}/timeline", h.getSessionTimeline)
	})
	// run-history dashboard 汇总（owner scope；admin 可全量）。
	r.Group(func(r chi.Router) {
		r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
		r.Get("/api/v1/acp/dashboard", h.getDashboard)
	})
	r.Route("/api/v1/acp/permissions", func(r chi.Router) {
		r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
		// 跨会话审批 inbox：调用者所有会话的待审权限请求（HITL）。
		r.Get("/", h.listPendingPermissions)
		r.Post("/{reqID}/approve", h.approvePermission)
		r.Post("/{reqID}/deny", h.denyPermission)
	})
	// Admin ACP 用量聚合（镜像 chat adminUsage）；service 层 requireAdmin。
	r.Group(func(r chi.Router) {
		r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
		r.Get("/api/v1/acp/usage", h.adminAcpUsage)
	})
}

// listSessionPermissions 返回某会话的待审权限请求（owner 或 admin）。
func (h *Handler) listSessionPermissions(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}
	reqs, err := h.permSvc.ListPendingBySession(r.Context(), c, id)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	out := make([]PermissionRequestDTO, 0, len(reqs))
	for _, p := range reqs {
		out = append(out, permissionRequestToDTO(p))
	}
	httpx.WriteJSON(w, http.StatusOK, out)
}

// listPendingPermissions 返回调用者跨所有会话的待审权限请求（HITL inbox）。
func (h *Handler) listPendingPermissions(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	reqs, err := h.permSvc.ListPendingForUser(r.Context(), c)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	out := make([]PermissionRequestDTO, 0, len(reqs))
	for _, p := range reqs {
		out = append(out, permissionRequestToDTO(p))
	}
	httpx.WriteJSON(w, http.StatusOK, out)
}

type approvePermissionReq struct {
	OptionID string `json:"option_id"`
}

func (h *Handler) approvePermission(w http.ResponseWriter, r *http.Request) {
	h.resolvePermission(w, r, true)
}

func (h *Handler) denyPermission(w http.ResponseWriter, r *http.Request) {
	h.resolvePermission(w, r, false)
}

func (h *Handler) resolvePermission(w http.ResponseWriter, r *http.Request, approve bool) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	reqID, perr := uuid.Parse(chi.URLParam(r, "reqID"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad request id"))
		return
	}
	var optionID string
	if approve {
		var body approvePermissionReq
		// 批准时 option_id 可选（缺省自动挑选 allow 类）；请求体可空。
		_ = json.NewDecoder(r.Body).Decode(&body)
		optionID = body.OptionID
	}
	if err := h.permSvc.Resolve(r.Context(), c, reqID, approve, optionID); err != nil {
		writeErr(w, r, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) caller(r *http.Request) (Caller, error) {
	uid, ok := middleware.UserIDFromContext(r.Context())
	if !ok {
		return Caller{}, errs.New(errs.CodeUnauthorized, "not authenticated")
	}
	admin, err := h.adminLookup.IsAdmin(r.Context(), uid)
	if err != nil {
		return Caller{}, err
	}
	return Caller{UserID: uid, IsAdmin: admin}, nil
}

func (h *Handler) listAgentKinds(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	ks, err := h.akSvc.List(r.Context(), c)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	if c.IsAdmin {
		out := make([]AgentKindAdminDTO, 0, len(ks))
		for _, k := range ks {
			out = append(out, h.agentKindToAdminDTO(k))
		}
		httpx.WriteJSON(w, http.StatusOK, out)
		return
	}
	out := make([]AgentKindPublicDTO, 0, len(ks))
	for _, k := range ks {
		out = append(out, agentKindToPublicDTO(k))
	}
	httpx.WriteJSON(w, http.StatusOK, out)
}

func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	var req CreateAgentKindReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
		return
	}
	enabled := true
	if req.Enabled != nil {
		enabled = *req.Enabled
	}
	modelID, perr := parseOptionalUUID(req.ModelID)
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad model_id"))
		return
	}
	in := CreateAgentKindInput{
		Name:                strings.TrimSpace(req.Name),
		DisplayName:         req.DisplayName,
		Description:         req.Description,
		BinaryPath:          strings.TrimSpace(req.BinaryPath),
		Args:                req.Args,
		Env:                 req.Env,
		Enabled:             enabled,
		ToolAllowlist:       req.ToolAllowlist,
		ClientType:          ClientType(req.ClientType),
		MCPServers:          req.MCPServers,
		ModelID:             modelID,
		MaxCostUSD:          req.MaxCostUSD,
		MaxTokens:           req.MaxTokens,
		MaxWallClockSeconds: req.MaxWallClockSeconds,
	}
	out, err := h.akSvc.Create(r.Context(), c, in)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusCreated, h.agentKindToAdminDTO(out))
}

func (h *Handler) getAgentKind(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}
	k, err := h.akSvc.Get(r.Context(), c, id)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	if c.IsAdmin {
		httpx.WriteJSON(w, http.StatusOK, h.agentKindToAdminDTO(k))
		return
	}
	httpx.WriteJSON(w, http.StatusOK, agentKindToPublicDTO(k))
}

func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}
	var req UpdateAgentKindReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
		return
	}
	in := UpdateAgentKindInput{
		DisplayName:   req.DisplayName,
		Description:   req.Description,
		BinaryPath:    req.BinaryPath,
		Args:          req.Args,
		Env:           req.Env,
		Enabled:       req.Enabled,
		ToolAllowlist: req.ToolAllowlist,
		MCPServers:    req.MCPServers,
	}
	if req.ClientType != nil {
		ct := ClientType(*req.ClientType)
		in.ClientType = &ct
	}
	if req.ModelID != nil {
		in.SetModelID = true
		if *req.ModelID == "" {
			in.ModelID = nil // 显式清空
		} else {
			mid, merr := uuid.Parse(*req.ModelID)
			if merr != nil {
				writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad model_id"))
				return
			}
			in.ModelID = &mid
		}
	}
	if req.MaxCostUSD != nil {
		in.SetMaxCostUSD = true
		in.MaxCostUSD = positiveOrNil(req.MaxCostUSD)
	}
	if req.MaxTokens != nil {
		in.SetMaxTokens = true
		if *req.MaxTokens > 0 {
			in.MaxTokens = req.MaxTokens
		}
	}
	if req.MaxWallClockSeconds != nil {
		in.SetMaxWallClock = true
		if *req.MaxWallClockSeconds > 0 {
			in.MaxWallClockSeconds = req.MaxWallClockSeconds
		}
	}
	out, err := h.akSvc.Update(r.Context(), c, id, in)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, h.agentKindToAdminDTO(out))
}

func (h *Handler) deleteAgentKind(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}
	if err := h.akSvc.Delete(r.Context(), c, id); err != nil {
		writeErr(w, r, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// ===== AgentKind 配置文件 endpoints =====

func (h *Handler) listConfigFiles(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}
	files, err := h.akSvc.ListConfigFiles(r.Context(), c, id)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	out := make([]ConfigFileDTO, 0, len(files))
	for _, f := range files {
		out = append(out, configFileToDTO(f))
	}
	httpx.WriteJSON(w, http.StatusOK, out)
}

func (h *Handler) upsertConfigFile(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}
	var req UpsertConfigFileReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
		return
	}
	out, err := h.akSvc.UpsertConfigFile(r.Context(), c, id, req.RelPath, req.Content)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, configFileToDTO(out))
}

func (h *Handler) deleteConfigFile(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}
	relPath := r.URL.Query().Get("rel_path")
	if err := h.akSvc.DeleteConfigFile(r.Context(), c, id, relPath); err != nil {
		writeErr(w, r, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// ===== Session endpoints =====

func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	f := SessionListFilter{All: r.URL.Query().Get("all") == "true"}
	if v := r.URL.Query().Get("requirement_id"); v != "" {
		id, err := uuid.Parse(v)
		if err != nil {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "requirement_id 非法"))
			return
		}
		f.RequirementID = &id
	}
	list, err := h.sessSvc.List(r.Context(), c, f)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	out := make([]SessionDTO, 0, len(list))
	for _, s := range list {
		out = append(out, sessionToDTO(s))
	}
	httpx.WriteJSON(w, http.StatusOK, out)
}

func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	var req CreateSessionReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
		return
	}
	wsID, perr := uuid.Parse(req.WorkspaceID)
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad workspace_id"))
		return
	}
	akID, perr := uuid.Parse(req.AgentKindID)
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad agent_kind_id"))
		return
	}
	in := CreateSessionInput{
		WorkspaceID:       wsID,
		AgentKindID:       akID,
		Branch:            req.Branch,
		IssueNumber:       req.IssueNumber,
		RequirementNumber: req.RequirementNumber,
		InitialPrompt:     req.InitialPrompt,
	}
	out, err := h.sessSvc.Create(r.Context(), c, in)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusCreated, sessionToDTO(out))
}

func (h *Handler) getSession(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}
	s, err := h.sessSvc.Get(r.Context(), c, id)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, sessionToDTO(s))
}

// getSessionUsage 返回会话累计成本/token + 最近账目（owner 或 admin）。
func (h *Handler) getSessionUsage(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}
	// 复用 sessSvc.Get 做 owner/admin 访问校验（NotFound/Forbidden 由其返回）。
	s, err := h.sessSvc.Get(r.Context(), c, id)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	const recentLimit = 50
	ledger, err := h.repo.ListSessionUsage(r.Context(), id, recentLimit)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	recent := make([]SessionUsageDTO, 0, len(ledger))
	for _, rec := range ledger {
		recent = append(recent, sessionUsageToDTO(rec))
	}
	httpx.WriteJSON(w, http.StatusOK, SessionUsageResponse{
		SessionID:        s.ID.String(),
		PromptTokens:     s.PromptTokens,
		CompletionTokens: s.CompletionTokens,
		ThinkingTokens:   s.ThinkingTokens,
		TotalCostUSD:     s.TotalCostUSD,
		BudgetMaxCostUSD: s.BudgetMaxCostUSD,
		BudgetMaxTokens:  s.BudgetMaxTokens,
		Recent:           recent,
	})
}

// adminAcpUsage 返回 ACP 用量聚合（admin only），group=user|model|day，镜像 chat。
func (h *Handler) adminAcpUsage(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	if !c.IsAdmin {
		writeErr(w, r, errs.New(errs.CodeForbidden, "admin only"))
		return
	}
	q := r.URL.Query()
	to := time.Now().UTC()
	from := to.AddDate(0, 0, -30)
	if v := q.Get("from"); v != "" {
		if t, e := time.Parse(time.RFC3339, v); e == nil {
			from = t
		}
	}
	if v := q.Get("to"); v != "" {
		if t, e := time.Parse(time.RFC3339, v); e == nil {
			to = t
		}
	}
	groupBy := q.Get("group")
	if groupBy == "" {
		groupBy = q.Get("group_by")
	}
	if groupBy == "" {
		groupBy = "day"
	}

	items := make([]AcpUsageItem, 0)
	switch groupBy {
	case "user":
		rows, rerr := h.repo.SummarizeAcpUsageByUser(r.Context(), from, to)
		if rerr != nil {
			writeErr(w, r, rerr)
			return
		}
		for _, row := range rows {
			items = append(items, AcpUsageItem{
				Key: row.UserID.String(), PromptTokens: row.PromptTokens,
				CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD,
			})
		}
	case "model":
		rows, rerr := h.repo.SummarizeAcpUsageByModel(r.Context(), from, to)
		if rerr != nil {
			writeErr(w, r, rerr)
			return
		}
		for _, row := range rows {
			items = append(items, AcpUsageItem{
				Key: row.ModelID.String(), PromptTokens: row.PromptTokens,
				CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD,
			})
		}
	case "day":
		rows, rerr := h.repo.SummarizeAcpUsageByDay(r.Context(), from, to)
		if rerr != nil {
			writeErr(w, r, rerr)
			return
		}
		for _, row := range rows {
			items = append(items, AcpUsageItem{
				Key: row.Day.Format(time.RFC3339), PromptTokens: row.PromptTokens,
				CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD,
			})
		}
	default:
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "invalid group"))
		return
	}
	httpx.WriteJSON(w, http.StatusOK, AcpUsageResponse{Items: items})
}

func (h *Handler) terminateSession(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}
	if err := h.sessSvc.Terminate(r.Context(), c, id); err != nil {
		writeErr(w, r, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) getEvents(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}
	if _, err := h.sessSvc.Get(r.Context(), c, id); err != nil {
		writeErr(w, r, err)
		return
	}
	var since int64
	if v := r.URL.Query().Get("since"); v != "" {
		if n, err := strconv.ParseInt(v, 10, 64); err == nil {
			since = n
		}
	}
	evs, err := h.repo.ListEventsSince(r.Context(), id, since, 1000)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	out := make([]EventEnvelope, 0, len(evs))
	for _, e := range evs {
		method := ""
		if e.Method != nil {
			method = *e.Method
		}
		out = append(out, EventEnvelope{
			ID:        e.ID,
			Direction: string(e.Direction),
			Kind:      string(e.RPCKind),
			Method:    method,
			Payload:   e.Payload,
			Truncated: e.Truncated,
		})
	}
	httpx.WriteJSON(w, http.StatusOK, out)
}

// getDashboard 返回 run-history 汇总（owner scope；admin 全量）。可选 query：
// project_id / requirement_id / from / to（RFC3339）。
func (h *Handler) getDashboard(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	q := r.URL.Query()
	var in DashboardInput
	if v := q.Get("project_id"); v != "" {
		id, perr := uuid.Parse(v)
		if perr != nil {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad project_id"))
			return
		}
		in.ProjectID = &id
	}
	if v := q.Get("requirement_id"); v != "" {
		id, perr := uuid.Parse(v)
		if perr != nil {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad requirement_id"))
			return
		}
		in.RequirementID = &id
	}
	if v := q.Get("from"); v != "" {
		t, terr := time.Parse(time.RFC3339, v)
		if terr != nil {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad from"))
			return
		}
		in.From = t
	}
	if v := q.Get("to"); v != "" {
		t, terr := time.Parse(time.RFC3339, v)
		if terr != nil {
			writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad to"))
			return
		}
		in.To = t
	}
	res, err := h.sessSvc.Dashboard(r.Context(), c, in)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	httpx.WriteJSON(w, http.StatusOK, dashboardToResponse(res))
}

// getSessionTimeline 返回单会话事件时间线（owner 或 admin）。
func (h *Handler) getSessionTimeline(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}
	buckets, err := h.sessSvc.Timeline(r.Context(), c, id)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	out := make([]SessionTimelineDTO, 0, len(buckets))
	for _, b := range buckets {
		out = append(out, sessionTimelineToDTO(b))
	}
	httpx.WriteJSON(w, http.StatusOK, out)
}

// ===== WebSocket =====

func (h *Handler) sessionWS(w http.ResponseWriter, r *http.Request) {
	c, err := h.caller(r)
	if err != nil {
		writeErr(w, r, err)
		return
	}
	id, perr := uuid.Parse(chi.URLParam(r, "id"))
	if perr != nil {
		writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
		return
	}

	sess, err := h.sessSvc.Get(r.Context(), c, id)
	if err != nil {
		writeErr(w, r, err)
		return
	}

	var since int64
	if v := r.URL.Query().Get("since"); v != "" {
		if n, err := strconv.ParseInt(v, 10, 64); err == nil {
			since = n
		}
	}

	conn, err := ws.Accept(w, r, &ws.AcceptOptions{
		InsecureSkipVerify: false,
		Subprotocols:       []string{"acp-v1"},
	})
	if err != nil {
		return
	}
	defer conn.Close(ws.StatusInternalError, "internal")

	ctx, cancel := context.WithCancel(r.Context())
	defer cancel()

	// 1. State event
	stateEv := map[string]any{
		"kind":       "state",
		"session_id": id.String(),
		"status":     string(sess.Status),
		"branch":     sess.Branch,
		"since":      since,
	}
	if err := wsjson.Write(ctx, conn, stateEv); err != nil {
		return
	}

	// 2. Push history
	history, _ := h.repo.ListEventsSince(ctx, id, since, int32(5000))
	var lastSentID = since
	for _, e := range history {
		env := EventEnvelope{
			ID:        e.ID,
			Direction: string(e.Direction),
			Kind:      string(e.RPCKind),
			Method:    derefStr(e.Method),
			Payload:   e.Payload,
			Truncated: e.Truncated,
		}
		if err := wsjson.Write(ctx, conn, env); err != nil {
			return
		}
		lastSentID = e.ID
	}

	// 3. Live: subscribe + heartbeat + client messages
	relay := h.sup.GetRelay(id)
	if relay == nil {
		conn.Close(ws.StatusNormalClosure, "session ended")
		return
	}
	sub := relay.Subscribe(c.UserID)
	defer relay.Unsubscribe(sub.ID)

	// Heartbeat goroutine
	pingTicker := time.NewTicker(h.cfg.PingInterval)
	defer pingTicker.Stop()
	go func() {
		for {
			select {
			case <-ctx.Done():
				return
			case <-pingTicker.C:
				pingCtx, pcancel := context.WithTimeout(ctx, h.cfg.PongTimeout)
				err := conn.Ping(pingCtx)
				pcancel()
				if err != nil {
					cancel()
					return
				}
			}
		}
	}()

	// Client → server reader (goroutine)
	clientMsgCh := make(chan *Message, 8)
	go func() {
		defer close(clientMsgCh)
		for {
			_, data, err := conn.Read(ctx)
			if err != nil {
				return
			}
			var m Message
			if err := json.Unmarshal(data, &m); err != nil {
				_ = wsjson.Write(ctx, conn, map[string]any{
					"kind":    "client_error",
					"message": "bad json",
				})
				continue
			}
			clientMsgCh <- &m
		}
	}()

	// Main loop
	for {
		select {
		case <-ctx.Done():
			return
		case env, ok := <-sub.Send:
			if !ok {
				conn.Close(ws.StatusNormalClosure, "relay closed")
				return
			}
			if env.ID <= lastSentID && env.ID != 0 {
				continue
			}
			if err := wsjson.Write(ctx, conn, env); err != nil {
				return
			}
			if env.ID > lastSentID {
				lastSentID = env.ID
			}
		case m, ok := <-clientMsgCh:
			if !ok {
				return
			}
			if c.UserID != sess.UserID {
				_ = wsjson.Write(ctx, conn, map[string]any{
					"kind":    "client_error",
					"message": "only owner can prompt/cancel",
				})
				continue
			}
			if m.Method != "session/prompt" && m.Method != "session/cancel" {
				_ = wsjson.Write(ctx, conn, map[string]any{
					"kind":    "client_error",
					"message": "method not allowed",
				})
				continue
			}
			// 回合相关联：用户从前端发起 session/prompt 时也登记回合，使其完成时
			// 走与 auto-prompt 相同的检测路径。client 自生成的字符串 id 作为相关联键。
			if m.Method == "session/prompt" {
				if tr := relay.Tracker(); tr != nil {
					if pid, ok := m.IDString(); ok && pid != "" {
						if _, err := tr.StartTurn(ctx, pid); err != nil {
							slog.Warn("acp.ws.start_turn", "session_id", sess.ID, "err", err.Error())
						}
					}
				}
			}
			if err := relay.SendClient(m); err != nil {
				_ = wsjson.Write(ctx, conn, map[string]any{
					"kind":    "client_error",
					"message": err.Error(),
				})
			}
		}
	}
}

// ===== DTO converters =====

// agentKindToAdminDTO is a Handler method because env-key extraction needs the encryptor.
func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
	envKeys := []string{}
	if len(k.EncryptedEnv) > 0 {
		if env, err := decryptEnv(h.enc, k.EncryptedEnv); err == nil {
			for key := range env {
				envKeys = append(envKeys, key)
			}
			sort.Strings(envKeys)
		}
	}
	mcpServers := []McpServerSpec{}
	if servers, err := decryptMCPServers(h.enc, k.EncryptedMCPServers); err == nil {
		mcpServers = servers
	}
	var modelID *string
	if k.ModelID != nil {
		v := k.ModelID.String()
		modelID = &v
	}
	return AgentKindAdminDTO{
		ID:                  k.ID,
		Name:                k.Name,
		DisplayName:         k.DisplayName,
		Description:         k.Description,
		BinaryPath:          k.BinaryPath,
		Args:                append([]string(nil), k.Args...),
		EnvKeys:             envKeys,
		Enabled:             k.Enabled,
		ToolAllowlist:       append([]string(nil), k.ToolAllowlist...),
		ClientType:          string(k.ClientType),
		MCPServers:          mcpServers,
		ModelID:             modelID,
		MaxCostUSD:          k.MaxCostUSD,
		MaxTokens:           k.MaxTokens,
		MaxWallClockSeconds: k.MaxWallClockSeconds,
		CreatedBy:           k.CreatedBy,
		CreatedAt:           k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
		UpdatedAt:           k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
	}
}

// parseOptionalUUID 解析一个可选的字符串 UUID。nil/空串 → (nil, nil)；非法 → 错误。
func parseOptionalUUID(s *string) (*uuid.UUID, error) {
	if s == nil || *s == "" {
		return nil, nil
	}
	id, err := uuid.Parse(*s)
	if err != nil {
		return nil, err
	}
	return &id, nil
}

// positiveOrNil 返回正数指针，否则 nil（用于"值 <=0 视为清空"语义）。
func positiveOrNil(f *float64) *float64 {
	if f == nil || *f <= 0 {
		return nil
	}
	return f
}

func agentKindToPublicDTO(k *AgentKind) AgentKindPublicDTO {
	return AgentKindPublicDTO{
		ID:          k.ID,
		Name:        k.Name,
		DisplayName: k.DisplayName,
		Enabled:     k.Enabled,
	}
}

// ===== transport helpers =====

func writeErr(w http.ResponseWriter, r *http.Request, err error) {
	httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
}

func derefStr(p *string) string {
	if p == nil {
		return ""
	}
	return *p
}
